Image Resizing
Lazy image resizing for the framework, shipped as a separate package: @adaptivestone/framework-module-resize. Upload only the original; generate resized variants on demand with sharp.
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. 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 |
|---|---|
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 locks | nothing |
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. Generate them once:
npx @adaptivestone/framework-module-resize resize-scaffold
It emits (into process.cwd(), or --out <dir>), never overwriting without --force:
| File | What it is |
|---|---|
src/resizer.ts | the construction site — new Resizer({ … }) |
src/models/ResizeTask.ts | thin class ResizeTask extends ResizeTaskModel {} shim (Mongo transport) |
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 — schema and behavior stay in the npm package (auto-updates, no drift). Other flags: --check (CI drift check), --eject (full editable model), --eager (eager-mode hosts), --force, --out <dir>.
How it works
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
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[]); missing variants are enqueued while the read returns without ever blocking on sharp. This keeps sharp + storage I/O off your HTTP create/update handlers.
Quick start (lazy mode, Mongo + S3)
1. Wire the Resizer in the scaffolded src/resizer.ts. All drivers are injected in one visible literal and 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
},
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 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; }
}
4. Run the worker as a separate process (gated by worker.enabled):
npm run cli ResizeWorker
5. 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 formatPublicUrls
Modes: lazy vs pre-warm vs eager
All 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 (default) | Pre-warm | Eager | |
|---|---|---|---|
| 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 | storage + media model only — no queue/worker |
| Best for | high volume, fast uploads, large catalogs | fast uploads and a warm cache by first read | low/bursty volume, small fully-used catalogs |
:::tip 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, and you'd rather every image be ready the instant an upload finishes. The stored shape is identical, so you can switch later or mix the three.
:::
Pre-warm keeps the lazy wiring (transport + worker) but pushes the catalog into the queue at upload, so previews are usually ready by the first read. It never blocks and never throws:
// 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 queue
Eager constructs the Resizer without a transport and generates synchronously from the 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
});
Drivers & seams
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 |
Reach the process-wide instance anywhere via getResizer() (throws a clear error 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 (QueueTransport, ResizeStorage, MediaStore, LockProvider, …) are exported from the main entry. The shipped S3Storage / SqsTransport options are listed in the README.
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 keyedfiltersand 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).
| Hook | Kind | Runs where |
|---|---|---|
resolveSizes | waterfall | read path (real ctx) |
beforeEnqueue | waterfall | read path (real ctx) |
formatPublicUrls | waterfall | read path (real ctx) |
onPreviewGenerated | observer | worker (ctx === {}) |
afterTaskComplete | observer | worker (ctx === {}) |
onTaskFailed | observer | per failed attempt (will retry) |
onTaskDeadLettered | observer | task 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 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 identity | keyed 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:
| Key | Default | Notes |
|---|---|---|
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.enabled | false | gate the worker process (env-driven in host) |
queue.maxAttempts | 5 | delivery count before dead-letter (like SQS maxReceiveCount) |
queue.taskTimeoutMs | 600000 | handleTask 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 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). - 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 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.