Skip to main content

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 locksnothing

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:

FileWhat it is
src/resizer.tsthe construction site — new Resizer({ … })
src/models/ResizeTask.tsthin class ResizeTask extends ResizeTaskModel {} shim (Mongo transport)
src/commands/ResizeWorker.tsone-line re-export of the module's worker command
src/config/resize.tseditable 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-warmEager
Generateon first read; resolve() enqueues missingat upload; prewarm() enqueues the cataloginline at upload via resizer.generate(...)
Needstransport + ResizeWorker + ResizeTask + lockssame as lazystorage + media model only — no queue/worker
Best forhigh volume, fast uploads, large catalogsfast uploads and a warm cache by first readlow/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).

SeamOptionShippedSubpath import
Queue transporttransport?MongoTransport, SqsTransport…/transports/mongo.js, …/transports/sqs.js
Storagestorage (required)S3Storage…/storage/s3.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 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 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 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-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.