Skip to content

adaptivestone/framework-module-resizer

Repository files navigation

@adaptivestone/framework-module-resize

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.


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
  1. Only the original is stored (typically a private bucket). No previews at upload time.
  2. Generated previews live as metadata on the host's media document (previews[]) — the source of truth for what is ready.
  3. resolve() returns a decision (ready[] + missing[]); the host turns it into its own response shape via the formatPublicUrls hook. Missing variants are enqueued; the read never blocks on sharp.
  4. 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.


Install

npm i @adaptivestone/framework-module-resize

Requires 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)

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({ … }) (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 storage TODO in src/resizer.ts, set mediaModelName in src/config/resize.ts, and import './resizer.ts' from src/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>.


Quick start (lazy mode, Mongo + S3)

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 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 (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 queue

Choose 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.


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

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).

MongoTransport

Option-less: new MongoTransport(). Backed by the scaffolded ResizeTask model; uses the config.queue lease/retry knobs. No optional deps.

SqsTransport({ … })

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.

S3Storage({ … })

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.

Custom driver = implement the interface

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 & 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 (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 keyed filters and anything sized relative to the output.

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.

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 (or persisted onto it earlier). 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 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.


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 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.


Config reference

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({...}).


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.)

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.


Host responsibilities

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-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 pass ctx.isOwner/ctx.isAdmin to opt a read into a signed-original URL.
  • SVG sanitization and deleting media / storage cleanup.

Testing

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.

License

MIT

About

Resized module for AdaptiveStone framework

Resources

License

Stars

1 star

Watchers

0 watching

Forks

Packages

 
 
 

Contributors