Lazy image resizing for @adaptivestone/framework.
Upload only the original; generate resized variants on demand. The read path decides,
per requested size + format + filters, whether a preview is ready or missing — ready ones return
immediately, missing ones are enqueued and generated by a separate worker with
sharp. Everything the module touches — the queue transport,
storage, the media store, the lock provider — is a swappable driver wired in one constructor
literal. The core owns only the identity, the read decision, and the resize pipeline.
Distilled from several prior production implementations of upload-time resizing, minus their synchronous all-or-nothing cost and their three incompatible response shapes.
Coding agents (Claude Code, Cursor, Codex, …): read
AGENTS.md— the machine-oriented integration guide that ships with this package.
upload ─▶ store the ORIGINAL only (no previews baked at upload)
read ─▶ resolve({ media, sizes }) ─┬─ ready? → return the URL now
└─ missing? → enqueue + return a placeholder/original
worker ─▶ download original → beforeSteps → per-variant resize + variantSteps + encode → upload
─▶ append preview to the media doc
next read ─▶ ready
- Only the original is stored (typically a private bucket). No previews at upload time.
- 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[]); the host turns it into its own response shape via theformatPublicUrlshook. Missing variants are enqueued; the read never blocks onsharp.- The worker consumes the queue, runs the pipeline, generates previews, uploads them, and appends them to the media doc. The next read returns real URLs.
This keeps sharp + storage I/O off your HTTP create/update handlers.
npm i @adaptivestone/framework-module-resizeRequires Node >=24 and the framework/mongoose peers (mandatory — a nested second framework copy
breaks the model loader). The AWS drivers are optional peers: install them only for the driver
you use. Each optional peer is resolved only when you import its driver subpath — 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 |
|---|---|
SQS transport (/transports/sqs.js) |
@aws-sdk/client-sqs sqs-consumer |
S3 storage (/storage/s3.js) |
@aws-sdk/client-s3 @aws-sdk/s3-request-presigner |
| Mongo transport / framework media store / framework locks | nothing (no optional deps) |
The framework discovers models and commands by scanning your src/ folder, so a few thin files
must live in your app. Generate them once:
npx @adaptivestone/framework-module-resize resize-scaffoldIt emits (into process.cwd(), or --out <dir>), never overwriting without --force:
| File | What it is |
|---|---|
src/resizer.ts |
the construction site — new Resizer({ … }) (edit freely) |
src/models/ResizeTask.ts |
thin class ResizeTask extends ResizeTaskModel {} shim (Mongo transport only) |
src/commands/ResizeWorker.ts |
one-line re-export of the module's worker command |
src/config/resize.ts |
editable config that spreads the module defaults |
The shims are not vendored copies — the schema/behavior stays in the npm package (auto-updates, no drift). On completion the command prints its 3 remaining TODOs:
Fill the
storageTODO insrc/resizer.ts, setmediaModelNameinsrc/config/resize.ts, andimport './resizer.ts'fromsrc/server.ts(so it runs in every process).
Other flags: --check (CI-gatable drift check; exits 1 on missing/drift, no writes), --eject
(write the full editable model instead of the shim, for custom fields/indexes), --eager
(eager-mode hosts: emit only src/resizer.ts + src/config/resize.ts), --agents <agents|claude|print|skip> (where to write the append-only, marker-idempotent pointer to the
shipped AGENTS.md; default agents = the host AGENTS.md), --force,
--out <dir>.
1. Wire the Resizer in the scaffolded src/resizer.ts. All drivers are injected in one visible
literal; drivers are fixed at construction (one Resizer per process — a second new Resizer()
throws).
// 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'; // optional AWS peers resolved only here
export const resizer = new Resizer({
transport: new MongoTransport(), // or new SqsTransport({ queueUrl, region }); omit for eager-only
storage: new S3Storage({ // REQUIRED — shipped driver or any custom ResizeStorage
bucketPublic: 'my-cdn',
bucketPrivate: 'my-originals',
publicUrl: 'https://cdn.example.com',
}),
// mediaStore / lockProvider omitted → framework-backed defaults
pipelines: {
default: {},
listing: { beforeSteps: [blurPlates] }, // async detector, applied once to the source
premium: {
variantSteps: [(img, { variant }) => variant.filters?.blur ? img.blur(Number(variant.filters.blur)) : img],
},
},
hooks: {
resolveSizes: (sizes, ctx) => ctx.entity === 'event' ? [...sizes, { fit: true }] : sizes,
formatPublicUrls: (decision, ctx) => toHostDto(decision, ctx), // your response shape + placeholders
},
});2. Import it once from src/server.ts so it runs in both the API and worker processes:
import './resizer.ts';3. Set your media model name in src/config/resize.ts (the one required 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'
};Your media model (File/Media) must carry original (incl. width/height) and previews[]
(incl. filters/fit). That schema is host-owned; to avoid hand-written drift the module exports
an opt-in as const fragment you can spread in (single source of truth for the runtime schema
and the types):
import { resizeMediaSchemaFragment } from '@adaptivestone/framework-module-resize';
class File extends BaseModel {
static get modelSchema() { return { ...existingFields, ...resizeMediaSchemaFragment } as const; }
}At upload capture original.width/height (from sharp metadata) onto the media doc; if you
don't, the worker backfills them on first process.
4. Run the worker as a separate process (gated by worker.enabled):
npm run cli ResizeWorker5. 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; // your own shape, produced by formatPublicUrlsAll three modes drive the same resize core and write the same previews[] shape, so you can
switch later with no data migration, or mix them.
| Lazy (queued, on read) — default | Pre-warm (queued, at upload) | Eager (sync, at upload) | |
|---|---|---|---|
| Generate | on first read; resolve() enqueues missing |
at upload; prewarm() enqueues the catalog |
inline at upload via resizer.generate(...) |
| Needs | transport + ResizeWorker + ResizeTask + locks |
same as lazy (transport + worker) | storage + media model only — no queue/worker |
| Best for | high volume, fast uploads, large/open-ended catalogs | fast uploads and a warm cache by first read | low/bursty volume, small fully-used catalogs, single-process |
Default to lazy. It keeps uploads fast and only does work that's actually needed. Choose eager when your app is low-volume, your size catalog is small and fully used, you don't want to run a worker, and you'd rather every image be ready the instant an upload finishes. You can switch later — the stored shape is identical — or mix the three.
Pre-warm — keep the lazy wiring (transport + worker), but push the catalog into the queue
at upload so the previews are usually ready by the first read: no sharp on the request path, no
waiting for the first reader. Never blocks and never throws (same guarantee as resolve); ctx
reaches the read-path waterfalls (the worker still runs with ctx === {}):
// upload handler, after the media doc is created:
await resizer.prewarm({ media: fileDoc, sizes: getListingSizes(), pipeline: 'listing' });
// → { enqueued } = how many variants were handed to the queueChoose pre-warm when you want fast uploads and a warm cache — the request returns immediately while the worker fills the catalog in the background.
Eager — construct the Resizer without a transport and call generate from your
upload handler (ctx reaches pipeline steps here, unlike the queued worker):
const { previews } = await resizer.generate({
media: fileDoc,
sizes: getEventMediaSizes(), // your catalog
pipeline: 'listing',
// persist: true (default) → $push previews + backfill dims; false → returns them for you to store
});Hybrid: generate the above-the-fold sizes at upload and let resolve lazily fill the heavy
ones on demand — or prewarm the whole catalog at upload and let resolve cover anything added
later. A host that starts eager can graduate to lazy (or pre-warm) with no migration.
Four seams, each a single active strategy fixed at construction. Two ship drivers; two default to
framework-backed drivers when omitted, so a standard host wires only transport + storage.
Every driver lives behind its own package subpath (the core entry never loads driver deps).
| Seam | Option | Shipped | Subpath import |
|---|---|---|---|
| Queue transport | transport? |
MongoTransport, SqsTransport |
…/transports/mongo.js, …/transports/sqs.js |
| Storage | storage (required) |
S3Storage |
…/storage/s3.js |
| Media store | mediaStore? |
FrameworkMediaStore (default) |
…/mediaStore/framework.js |
| Lock provider | lockProvider? |
FrameworkLockProvider (default) |
…/locks/framework.js |
storage is the one required option (both modes need it). transport is optional (omit for
eager-only). mediaStore/lockProvider default to the framework drivers. Reach the process-wide
instance anywhere via getResizer() (throws a clear error if none was constructed).
Option-less: new MongoTransport(). Backed by the scaffolded ResizeTask model; uses the
config.queue lease/retry knobs. No optional deps.
| Option | ||
|---|---|---|
queueUrl |
required | the SQS queue URL |
region, endpoint |
optional | AWS region / custom endpoint |
visibilityTimeout |
optional | seconds; passed to sqs-consumer |
heartbeatInterval |
optional | seconds; extends visibility during long resizes (SQS analog of the Mongo lease heartbeat) |
client |
optional | bring-your-own configured SQSClient (else built from region/endpoint on first use) |
Credentials are never options — they resolve via the standard AWS provider chain. Dead-lettering is
native (configure the queue's redrive policy with maxReceiveCount = config.queue.maxAttempts);
onTaskDeadLettered does not fire for SQS.
| Option | ||
|---|---|---|
bucketPublic |
required | previews land here (public visibility) |
bucketPrivate |
optional | originals (private); defaults to bucketPublic |
publicUrl |
optional | CDN/base URL for public objects |
region, endpoint, forcePathStyle |
optional | S3-compatible targets (MinIO / localstack / R2) |
client |
optional | bring-your-own configured S3Client |
publicUrl() is pure and I/O-free (called on the read path). No per-object ACL — public access
is a bucket policy. Credentials via the AWS provider chain. download/publicUrl/signedUrl
enforce a bucket allowlist: a stored ref.bucket must be one of the configured
bucketPublic/bucketPrivate, else they throw a named error — so a tampered media-doc bucket
can never become a cross-bucket read or an attacker-controlled hostname in a URL.
Any seam takes a plain object (or class) that satisfies the interface — no app parameter; it
closes over its own client. For plain S3 use S3Storage; for anything else (GCS, filesystem, R2):
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),
}});The same pattern swaps mediaStore (e.g. another DB/ORM) or lockProvider (e.g. Redis/redlock).
Contract types (QueueTransport, ResizeStorage, MediaStore, LockProvider, …) are exported
from the main entry for custom-driver authors.
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 (bootstrap runs in both processes).
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 (hard-stop on, e.g., an NSFW verdict).variantSteps— ordered (registration order matters) per-variant chain, after resize, before encode. The home for keyedfiltersand anything sized relative to the output.
⚠ Put a watermark in
variantSteps, notbeforeSteps. Baked onto the original once, a watermark scales down with each variant and becomes unreadable on small sizes.
ctxdoes NOT cross the queue. In the lazy workerctx === {}— the task carries only{ mediaId, pipeline, previews }. Durable per-media data a step needs must be read from the loadedmediadoc (or persisted onto it earlier). The full callerctxreaches 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).
| Hook | Kind | Signature | Runs where |
|---|---|---|---|
resolveSizes |
waterfall | (sizes, ctx) => sizes |
read path (real ctx) |
beforeEnqueue |
waterfall | (missing, ctx) => missing |
read path (real ctx) |
formatPublicUrls |
waterfall | (decision, ctx) => unknown |
read path (real ctx) |
onPreviewGenerated |
observer | (preview, ctx) |
worker (ctx === {}) |
afterTaskComplete |
observer | (task, ctx) |
worker (ctx === {}) |
onTaskFailed |
observer | (task, error, ctx) |
per failed attempt (will retry) |
onTaskDeadLettered |
observer | (task, error, ctx) |
task exhausted maxAttempts (host can alert/page) |
Register at construction (hooks:) or later via getResizer().hook(name, fn) (appends). Every
observer is also mirrored on the framework event bus as resize:<name> (e.g.
resize:onTaskDeadLettered), fire-and-forget, for ecosystem subscribers — but the typed hook()
registry stays the primary contract because it is awaited and error-isolated.
Taps are typed (HookSignatures): hooks: and hook(name, fn) infer each tap's exact
signature from its name, so autocomplete works and a wrong argument/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 the Mongo and SQS transports —
never a raw driver document — so a host tap is portable across transports.
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 input | Size key | Meaning |
|---|---|---|
{ width: 300, height: 300 } |
300x300 |
cropped (cover) |
{ width: 620 } |
620w |
width-only (banner/strip) |
{ height: 400 } |
400h |
height-only |
{ fit: true } |
fit |
uncropped ("contain"), bounded by config.maxSize |
{ width: 300, height: 300, filters: { blur: 40 } } |
300x300 + blur:40 in the identity |
keyed alternate rendering |
The host owns the size catalogs per entity, injected via resolveSizes + per-call sizes.
Illustrative catalogs (entity names are generic examples, not prescriptive):
| Entity | Sizes |
|---|---|
| gallery / detail image | 1760x990, 618x360 |
| banner / strip (width-only) | 620w |
| avatar | 200x200, 160x160, 80x80, 50x50 |
| thumbnail set | 100x70, 200x140, 400x280, 800x560 |
| full gallery + uncropped view | 933x700, 1866x1400, 360x270, fit |
| preview | 150x150, 200x200, 400x400 |
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.
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 (so
formats: ['webp','avif'] doesn't concat to five); nested objects merge field-by-field.
| Key | Default | Notes |
|---|---|---|
mediaModelName |
— (required) | your host media model name ('File'/'Media') |
formats |
['jpeg','webp','avif'] |
generated formats |
webpAvifOnly |
false |
when true, requiredFormats() drops jpeg (read + worker must agree) |
maxSize |
{ width: 2000, height: 1200 } |
the fit cap |
animated |
false |
true keeps GIF/WebP frames |
encode.quality |
{ jpeg: 80, webp: 82, avif: 64 } |
per-format — sharp codec defaults aren't perceptually comparable; never reuse one int |
encode.effort |
{ webp: 4, avif: 4 } |
encode-once + CDN-cached, so 5–6 is often worth it |
encode.mozjpeg |
true |
progressive + trellis quantization |
encode.chromaSubsampling |
'4:2:0' |
'4:4:4' keeps full chroma for text/logos/UI |
encode.sharpen |
{ cover: true, fit: false } |
mild unsharp after downscale (off for the large modal) |
encode.flattenBackground |
'#ffffff' |
alpha → jpeg flatten color |
limits.inputPixels |
268402689 |
sharp decoder bomb guard |
limits.sourcePixels |
50_000_000 |
rejected before decode, from metadata |
limits.resultDimension |
5000 |
clamp on the cover branch |
limits.animationFrames |
64 |
animation-bomb guard |
queue.lockTtlMs |
{ dispatch: 60000, worker: 60000 } |
worker ≤ leaseMs |
queue.leaseMs |
60000 |
heartbeat renews at leaseMs/2; set ≥ ~2× worst-case encode |
queue.retryBackoffMs |
{ base: 5000, max: 300000 } |
delayed re-lease on fail |
queue.maxAttempts |
5 |
delivery count before dead-letter (increments on every lease incl. reclaims, like SQS maxReceiveCount) |
queue.idlePollMs |
1000 |
empty-lease sleep |
queue.taskTimeoutMs |
600000 |
handleTask is raced against this; on timeout the task is failed and the slot freed (Mongo transport) |
worker.enabled |
false |
gate the worker process (env-driven in host) |
worker.concurrency |
4 |
variants resized in parallel per task |
worker.sharpConcurrency |
1 |
sharp.concurrency(); keep concurrency × sharpConcurrency ≈ nCPU |
worker.sharpCache |
false |
a worker processes distinct images; the op-cache mostly wastes memory |
Storage buckets/URLs and the SQS queue URL are not config — they are driver options passed to
new S3Storage({...}) / new SqsTransport({...}).
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.)
Retention TTLs: completed rows evict after 24h; dead rows are kept ~30 days for
inspection/replay (edit the expireAfterSeconds in the scaffolded model to taste).
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.
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. SVG
sanitization is host-owned (sanitize at upload before storing).
Deleting media / storage cleanup is host-owned. The module appends previews but does not delete them; removing a media doc's storage objects (originals + derivatives) is your lifecycle.
The module owns the resize core; the host owns everything domain-specific (spec §15):
- The public response DTO shape (via
formatPublicUrls). - Which domain models attach media and the size catalogs per entity (via
resolveSizes+ per-callsizes— 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 pass
ctx.isOwner/ctx.isAdminto opt a read into a signed-original URL. - SVG sanitization and deleting media / storage cleanup.
The framework enforces one app instance per process; tests install a fake via setAppInstance(fake)
/ resetAppInstance() (the node:test runner isolates each file in its own process). Build fresh
Resizers with resetResizerForTests() between constructions. Run the full node:test suite with
npm test.
MIT