Skip to main content

Image Resizing

Image resizing for the framework, shipped as a separate package: @adaptivestone/framework-module-resize. Upload only the original; generate resized variants with sharp.

It runs in two shapes. Eager generates variants inline at upload and needs nothing but storage — no queue, no worker, no extra model. Lazy defers generation to a background worker, so uploads stay fast and only the sizes actually requested get built. Both drive the same resize core and write the same previews[] shape, so you can start eager and switch later with no data migration.

Everything the module touches — queue transport, storage, media store, lock provider — is a swappable driver wired in one constructor literal.

Installation

npm i @adaptivestone/framework-module-resize

Requires Node >=24 and the framework/mongoose peers (mandatory). The AWS SDKs are optional peers — install only the driver you use. Each is resolved only when you import its driver subpath, so the main entry never loads the AWS SDKs, and a missing peer fails loudly at your own import line at bootstrap, not at first I/O.

You use…Also install
Local filesystem storage (/storage/fs.js)nothing
S3 storage (/storage/s3.js)@aws-sdk/client-s3 @aws-sdk/s3-request-presigner
SQS transport (/transports/sqs.js)@aws-sdk/client-sqs sqs-consumer
Mongo transport / framework media store / framework locksnothing

Quick start (eager + local filesystem)

The smallest thing that works. No queue, no worker, no ResizeTask model — just storage and your media model.

1. Construct the Resizer after Server.init() (or lazily on first request — never in src/server.ts before startServer()):

import { Resizer } from '@adaptivestone/framework-module-resize';
import { LocalFsStorage } from '@adaptivestone/framework-module-resize/storage/fs.js';

export const resizer = new Resizer({
storage: new LocalFsStorage({
rootDir: './var/media', // files land here
publicBaseUrl: '/media', // URL prefix for publicUrl()
}),
});

storage is the one required option — both the read path (publicUrl) and generation (download/upload) need it. Omitting it throws a ResizeSetupError at construction rather than a confusing TypeError later.

2. Set your media model name in src/config/resize.ts — the one required config field:

import defaultResizeConfig from '@adaptivestone/framework-module-resize/config/resize.js';

export default {
...defaultResizeConfig,
mediaModelName: 'File', // your host media model, e.g. 'File' or 'Media'
};

3. Generate at upload, and read wherever you build a DTO:

// upload handler — after media.original is stored
const { created, failed } = await resizer.generate({
media: fileDoc,
sizes: [{ width: 320, height: 320 }, { fit: true }],
});

// read path
const { decision } = await resizer.resolve({
media: fileDoc,
sizes: [{ width: 320, height: 320 }, { fit: true }],
});

That is a complete working setup. LocalFsStorage is also the right answer for tests and CI — no AWS, no mocks.

Scaffold the integration files

The framework discovers models and commands by scanning your src/ folder, so a few thin files must live in your app:

npx @adaptivestone/framework-module-resize resize-scaffold --eager

--eager emits the filesystem-storage construction site and skips the queue files entirely. Drop the flag to get the full lazy set. Files land in process.cwd() (or --out <dir>) and are never overwritten without --force:

FileWhat it isEager
src/resizer.tsthe construction site — new Resizer({ … })
src/config/resize.tseditable config that spreads the module defaults
src/models/ResizeTask.tsthin class ResizeTask extends ResizeTaskModel {} shimlazy only
src/commands/ResizeWorker.tsone-line re-export of the module's worker commandlazy only

The shims are not vendored copies — schema and behavior stay in the npm package (auto-updates, no drift). Other flags: --check (CI drift check), --eject (full editable model), --force, --out <dir>.

The media schema

Your media model must carry original (incl. width/height) and previews[] (incl. filters/fit). That schema is host-owned; to avoid drift the module exports an opt-in as const fragment you can spread in:

import { resizeMediaSchemaFragment } from '@adaptivestone/framework-module-resize';

class File extends BaseModel {
static get modelSchema() { return { ...existingFields, ...resizeMediaSchemaFragment } as const; }
}

How it works

upload ─▶ store the ORIGINAL only (no previews baked in)
─▶ eager: generate() now · lazy: nothing · pre-warm: enqueue the catalog
read ─▶ resolve({ media, sizes }) ─┬─ ready? → return the URL now
└─ missing? → (lazy) enqueue + return a placeholder
worker ─▶ download original → beforeSteps → per-variant resize + variantSteps + encode → upload
─▶ append preview to the media doc

Generated previews live as metadata on the host's media document (previews[]) — the source of truth for what is ready. resolve() returns a decision (ready[] + missing[]) and never blocks on sharp, which keeps image work off your HTTP handlers.

When listings are huge (lazy / queue)

Once a catalog is large enough that generating everything at upload is wasteful, move generation to a worker. Same core, same stored shape — you add a transport, a worker process, and (for Mongo) the ResizeTask model.

1. Add a transport and storage to the constructor:

// src/resizer.ts — imported by src/server.ts so it runs in EVERY process (API + worker)
import { Resizer } from '@adaptivestone/framework-module-resize';
import { MongoTransport } from '@adaptivestone/framework-module-resize/transports/mongo.js';
import { S3Storage } from '@adaptivestone/framework-module-resize/storage/s3.js';

export const resizer = new Resizer({
transport: new MongoTransport(), // or new SqsTransport({ queueUrl, region })
storage: new S3Storage({
bucketPublic: 'my-cdn',
bucketPrivate: 'my-originals',
publicBaseUrl: 'https://cdn.example.com',
}),
// mediaStore / lockProvider omitted → framework-backed defaults
pipelines: {
default: {},
listing: { beforeSteps: [blurPlates] }, // async detector, applied once to the source
},
hooks: {
resolveSizes: (sizes, ctx) => ctx.entity === 'event' ? [...sizes, { fit: true }] : sizes,
formatPublicUrls: (decision, ctx) => toHostDto(decision, ctx), // your response shape
},
});

:::warning Use publicBaseUrl, not publicUrl

The S3 option is publicBaseUrl. The old publicUrl name still works for one minor but is deprecated — it collides with the publicUrl(ref) method every storage driver implements, which silently breaks anyone who copies the option literal into a class.

:::

2. Import it once from src/server.ts so it runs in both the API and worker processes:

import './resizer.ts';

3. Run the worker as a separate process (gated by worker.enabled):

npm run cli ResizeWorker

4. Read from your DTO builders. No app argument — the module reads the ambient app instance. resolve returns both the raw decision and the output of your formatPublicUrls hook:

import { resizer } from '../resizer.ts'; // or: getResizer()

const { output } = await resizer.resolve({
media: fileDoc,
pipeline: 'listing',
sizes: [
{ width: 1760, height: 990 },
{ width: 620 },
{ fit: true },
{ width: 300, height: 300, filters: { blur: 40 } },
],
ctx: { entity: 'event', isOwner },
});
return output;

:::note output is undefined without a hook

If you registered no formatPublicUrls tap — or every tap threw — output is undefined, not the raw decision. The module will never hand { ready, missing } to your frontend as if it were a DTO. Read decision yourself, or use formatPictureUrls below.

:::

resolve enqueues missing variants only when a transport exists: enqueueMissing defaults to true with a transport and false without one, so an eager-only host never enqueues-or-logs on every read. Pass it explicitly to override per call:

await resizer.resolve({ media, sizes, enqueueMissing: false }); // read-only; never queue

Modes: eager vs pre-warm vs lazy

All three drive the same resize core and write the same previews[] shape, so you can switch later with no data migration, or mix them.

EagerPre-warmLazy
Generateinline at upload via generate()at upload; prewarm() enqueues the catalogon first read; resolve() enqueues missing
Needsstorage + media model only — no queue/workertransport + ResizeWorker + ResizeTask + lockssame as pre-warm
Best forlow/bursty volume, small fully-used catalogsfast uploads and a warm cache by first readhigh volume, large catalogs, fast uploads

:::tip Start eager

Eager is a complete, first-class mode — not a toy. Reach for the queue when your catalogs get big enough that building every size at upload wastes real work. The stored shape is identical either way.

:::

Pre-warm keeps the lazy wiring but pushes the catalog into the queue at upload, so previews are usually ready by the first read. It never blocks and never throws — with no transport it logs once and returns { enqueued: 0 }:

await resizer.prewarm({ media: fileDoc, sizes: getListingSizes(), pipeline: 'listing' });
// → { enqueued } = how many variants were handed to the queue

Eager generates synchronously (and ctx reaches pipeline steps here, unlike the queued worker):

const { created, failed } = await resizer.generate({
media: fileDoc,
sizes: getEventMediaSizes(),
pipeline: 'listing',
// persist: true (default) → $push previews + backfill dims; false → returns them for you to store
});

Reading the generate result

created is only what this call made — not the full set on the document. A second generate with the same catalog returns { created: [], failed: 0 } because everything already exists. Treat an empty created as "nothing new was needed", never as failure:

CaseResult
Every requested variant already stored{ created: [], failed: 0 } — success
SVG original (pass-through, never rasterized){ created: [], failed: 0 } — success
Some variants failedno throw; failed > 0, created holds the rest
No media.originalthrows ResizeNoOriginalError
Every requested variant failedthrows ResizeGenerateError

Errors

Every error the module throws extends ResizeError, so one check separates "the resize module rejected this" from a sharp crash or an S3 timeout. The subclass answers what to do about it:

ClassWhat it meansWhat to do
ResizeSetupErrorwiring/bootstrap is wrongfix your code; retrying never helps
ResizeConfigErrorhost config invalid or violates an invariantcrash at boot
ResizeMediaErrorthis media record is unusableskip it; don't retry
└ ResizeNoOriginalErrorgenerate called with no originalupload the source first
ResizeGenerateErrorthe operation produced nothinginspect failed / requested
ResizeStorageErrortransient storage I/Oa retry may help
ResizeSecurityErrora refusal (path traversal, cross-bucket)never retry; log loudly
import { ResizeError, ResizeNoOriginalError } from '@adaptivestone/framework-module-resize';

try {
await resizer.generate({ media, sizes });
} catch (err) {
if (err instanceof ResizeNoOriginalError) return badRequest('upload the image first');
if (ResizeError.isResizeError(err)) return badRequest(err.message); // any module rejection
throw err; // not ours — let it bubble
}

Each error also carries a stable machine-readable err.code (RESIZE_NO_ORIGINAL, RESIZE_FS_PATH_TRAVERSAL, …) for logging and alerting.

:::note Prefer isResizeError over instanceof across package boundaries

If two copies of the package end up in one node_modules tree, the class identities differ and instanceof silently returns false. ResizeError.isResizeError(err) checks a registered symbol instead, so it keeps working.

:::

Drivers & seams

Four seams, each a single active strategy fixed at construction. Two default to framework-backed drivers when omitted, so a standard host wires only storage (+ transport if lazy). Every driver lives behind its own package subpath, so the core entry never loads driver deps.

SeamOptionShippedSubpath import
Storagestorage (required)LocalFsStorage, S3Storage…/storage/fs.js, …/storage/s3.js
Queue transporttransport?MongoTransport, SqsTransport…/transports/mongo.js, …/transports/sqs.js
Media storemediaStore?FrameworkMediaStore (default)…/mediaStore/framework.js
Lock providerlockProvider?FrameworkLockProvider (default)…/locks/framework.js

Reach the process-wide instance anywhere via getResizer() (throws a ResizeSetupError if none was constructed).

Custom driver = implement the interface. Any seam takes a plain object (or class) that satisfies its contract — no app parameter; it closes over its own client:

new Resizer({ /* … */, storage: {
download: (ref) => s3.getObject(ref.bucket!, ref.key),
upload: async ({ key, body, contentType, visibility }) => {
const bucket = visibility === 'public' ? 'my-cdn' : 'my-originals';
await s3.putObject(bucket, key, body, contentType);
return { bucket, key }; // ← persisted onto the preview/original
},
publicUrl: (ref) => `https://cdn.example.com/${ref.key}`, // pure; no I/O
signedUrl: (ref, ttl) => s3.getSignedUrl(ref.bucket!, ref.key, ttl),
}});

Contract types (ResizeStorage, QueueTransport, MediaStore, LockProvider, …) are exported from the main entry. The shipped driver options are listed in the README.

Helpers

Small exports that save every host from rewriting the same glue:

import {
formatPictureUrls, isCatalogCovered, resizeMediaPaths,
} from '@adaptivestone/framework-module-resize';

formatPictureUrls(decision, { id?, mediaType? }) builds a generic <picture>-shaped map from a decision — a convenience, not a mandated DTO. Filtered variants are excluded; sizeKey stays whatever your identity already is:

{ mediaType?, id?, sizes: { [sizeKey]: { [format]: { url, contentType } } } }

isCatalogCovered(media, sizes, formats) returns true when every identity already exists (or the original is an SVG) — use it to skip a no-op generate/prewarm.

resizeMediaPaths is the ['original', 'previews'] as const list of fields the module reads, for your .select(). Append your own:

File.find(query).select([...resizeMediaPaths, 'mediaType', 'name']).lean();

Pipelines & hooks

Pipelines are named per-media-type pixel work, selected per read call by name. The worker runs in a separate process, so the task carries only the pipeline name — the worker resolves the functions from its own registry.

pipelines: {
photo: {
beforeSteps: [detectAndBlurPlates, detectAndBlurFaces], // run ONCE on the source, before any resize
variantSteps: [(img, { variant }) => variant.filters?.blur ? img.blur(Number(variant.filters.blur)) : img],
},
avatar: {}, // no special processing
}
// later / from another module: getResizer().registerPipeline('premium', { … }) (last-wins per name)
  • beforeSteps — ordered, awaited, once per task on the source buffer. The home for detection metadata and pixel redaction (plate/face blur) that must apply to every variant. A throwing step fails the task.
  • variantSteps — ordered per-variant chain, after resize, before encode. The home for keyed filters and anything sized relative to the output.

:::warning Watermark in variantSteps

Put a watermark in variantSteps, not beforeSteps. Baked onto the original once, a watermark scales down with each variant and becomes unreadable on small sizes.

:::

:::note ctx does NOT cross the queue

In the lazy worker ctx === {} — the task carries only { mediaId, pipeline, previews }. Durable per-media data a step needs must be read from the loaded media doc. The full caller ctx reaches steps only in eager mode (generate, same process).

:::

Hooks are the cross-cutting seams. Taps run in registration order, awaited sequentially, and are error-isolated (a throwing tap is logged, never breaks the read/worker flow).

HookKindRuns where
resolveSizeswaterfallread path (real ctx)
beforeEnqueuewaterfallread path (real ctx)
formatPublicUrlswaterfallread path (real ctx)
onPreviewGeneratedobserverworker (ctx === {})
afterTaskCompleteobserverworker (ctx === {})
onTaskFailedobserverper failed attempt (will retry)
onTaskDeadLetteredobservertask exhausted maxAttempts (host can alert/page)

Register at construction (hooks:) or later via getResizer().hook(name, fn). Taps are typed (HookSignatures): each name infers its exact signature, so a wrong argument or return shape is a compile error instead of a silent any. In every observer the task argument is the transport-agnostic LeasedTask ({ taskId, mediaId, pipeline, previews }) on both transports — never a raw driver document — so a host tap is portable. Every observer is also mirrored on the framework event bus as resize:<name> (fire-and-forget) for ecosystem subscribers.

Sizes & identity

A size becomes a canonical size key via getSizeKey, and the full lookup/lock identity is sizeKey:format:filterSig. Filters are part of identity (empty → none), so a blurred variant is a distinct object.

Size inputSize keyMeaning
{ width: 300, height: 300 }300x300cropped (cover)
{ width: 620 }620wwidth-only (banner/strip)
{ height: 400 }400hheight-only
{ fit: true }fituncropped ("contain"), bounded by config.maxSize
{ width: 300, height: 300, filters: { blur: 40 } }300x300 + blur:40 in identitykeyed alternate rendering

The host owns the size catalogs per entity, injected via resolveSizes + per-call sizes.

:::warning Security: the catalog is an allowlist

Never pass raw client-supplied dimensions into sizes — resolve them against a fixed per-entity catalog first, or you invite arbitrary-resize resource abuse. The module owns the identity key; the host owns which sizes are permitted.

:::

Configuration

src/config/resize.ts (scaffolded, editable) spreads the module defaults and is deep-merged over them by getResizeConfig() — override any knob at any depth. Arrays REPLACE; nested objects merge field-by-field. The most-touched knobs:

KeyDefaultNotes
mediaModelName— (required)your host media model name ('File'/'Media')
formats['jpeg','webp','avif']generated formats
maxSize{ width: 2000, height: 1200 }the fit cap
encode.quality{ jpeg: 80, webp: 82, avif: 64 }per-format — never reuse one int across codecs
worker.enabledfalsegate the worker process (env-driven in host)
queue.maxAttempts5delivery count before dead-letter (like SQS maxReceiveCount)
queue.taskTimeoutMs600000handleTask is raced against this; on timeout the task is failed and the slot freed (Mongo transport)

Storage buckets/URLs and the SQS queue URL are not config — they are driver options passed to new LocalFsStorage({...}) / new S3Storage({...}) / new SqsTransport({...}). See the full config reference for every knob (encode, limits, queue lease/backoff, worker concurrency).

Operations

ResizeTask lifecycle (Mongo transport): pending → processing → completed | dead. Retries are capped at queue.maxAttempts, then the task is dead-lettered (status:'dead') — the lease never reclaims a task past the cap, so no crash-loop runs forever. (SQS uses its native DLQ instead.)

Dead-letter replay is a host op — reset the row:

ResizeTask.updateOne({ _id }, { $set: { status: 'pending', attempts: 0, leaseExpiresAt: null } });

Delivery is at-least-once (both transports); the worker is idempotent — re-running a task for an already-generated identity skips via the existing-preview check, never duplicates.

:::warning SVG sanitization is host-owned

SVG originals are pass-through — when original.contentType === 'image/svg+xml' the read path serves the original at every requested size/format and never resizes or enqueues. Sanitize SVGs at upload, before storing.

:::

Host responsibilities

The module owns the resize core; the host owns everything domain-specific:

  • The public response DTO shape (via formatPublicUrls, or formatPictureUrls as a starting point).
  • Which domain models attach media and the size catalogs per entity (via resolveSizes + per-call sizes — treat catalogs as allowlists).
  • Data migration from any legacy preview schema.
  • Domain image analysis — NSFW/object detection, plate/face blur, watermark, masking (inject via pipeline beforeSteps/variantSteps).
  • Permissions — who may delete/replace media; the host may opt a read into a signed-original URL via ctx.
  • SVG sanitization and deleting media / storage cleanup (the module appends previews but never deletes them).

For the exhaustive tables (every driver option, config knob, and hook signature) see the README.