Image Resizing
@adaptivestone/framework-module-resize makes resized copies of uploaded images. You store the uploaded original once. The module then creates previews, such as a 320×320 thumbnail and a 620-pixel-wide detail image, with sharp, stores them, and records them on your media document. When you build a response, it turns that record into image URLs.
- Original: the uploaded file, stored unchanged and privately.
- Preview: a generated image file. Its metadata is stored in the media document's
previews[]. - Variant: one requested size and format, plus optional filters. One size in JPEG, WebP and AVIF is three variants, and so three files.
The module handles resizing, storage calls and deciding which URLs are ready. Your app handles the upload endpoint, the media model, access checks, the response shape and deleting files.
Choose a workflow
| You want… | Call | The call waits for… | It returns |
|---|---|---|---|
| Previews ready when the upload request finishes | generate(): eager | resizing, upload and saving | { created, failed } |
| A fast upload, with previews made in the background | prewarm(): pre-warm | queueing and its confirmation | { status, … } per variant |
| Previews only for the sizes readers actually request | resolve() with a queue: lazy | queueing of the missing variants | { decision, output } |
Every workflow reads URLs with resolve() and writes the same previews[]. You can mix them, for example pre-warm thumbnails and let detail sizes generate lazily. You can also switch later without migrating data. Start with eager, and add the queue when uploads must stay fast.
Set up
1. Install and scaffold
You need Node >=24, @adaptivestone/framework ^5.0.1, and mongoose. From your app's root:
npm i @adaptivestone/framework-module-resize
npm exec --package=@adaptivestone/framework-module-resize -- resize-scaffold --eager
resize-scaffold ships with the package.
--eagercreatessrc/resizer.tsandsrc/config/resize.ts. Without it, the command also creates the queue model and the worker command (see background generation).- It never overwrites existing files;
--forcedoes. - It adds a pointer to the package's agent guide to your
AGENTS.md. Use--agents claude|print|skipto change that. - In CI, run
resize-scaffold --check(--check --eagerfor eager apps). It fails when a generated file is missing or has been changed in a way that breaks it.
2. Add the fields to your media model
// src/models/File.ts
import { BaseModel } from '@adaptivestone/framework/modules/BaseModel.js';
import { resizeMediaSchemaFragment } from '@adaptivestone/framework-module-resize';
export default class File extends BaseModel {
static get modelSchema() {
return { name: { type: String }, ...resizeMediaSchemaFragment } as const;
}
}
The fragment adds:
original: where the original is stored, plus its format, size and dimensions.previews[]: one entry per generated file.
Keep Mongoose's minimize: false, which is the BaseModel default, because storage locators can contain empty objects. Save the media document before you generate previews; the module never creates it.
3. Configure
// src/config/resize.ts
import type { FrameworkResizeConfig } from '@adaptivestone/framework-module-resize/framework.js';
import { defaultFrameworkResizeConfig } from '@adaptivestone/framework-module-resize/config/resize.js';
export default {
...defaultFrameworkResizeConfig,
mediaModelName: 'File', // your media model
} satisfies FrameworkResizeConfig;
Always spread the defaults. The module validates the complete object and throws ResizeConfigError if anything is missing. Put environment-specific changes in resize.production.ts and similar files; the framework merges them. All options are listed under configuration.
4. Create the Resizer
// src/resizer.ts
import { createFrameworkResizer } from '@adaptivestone/framework-module-resize/framework.js';
import { LocalFsStorage } from '@adaptivestone/framework-module-resize/drivers/fs.js';
export const resizer = createFrameworkResizer({
storage: new LocalFsStorage({ rootDir: './var/media', publicBaseUrl: '/media' }),
});
createFrameworkResizer takes its config from src/config/resize.ts, and adds the app logger and the media store for mediaModelName. You pass the storage. Import src/resizer.ts wherever you need the Resizer; a normal static import is fine, because nothing is read from the framework until first use. Elsewhere in your code, getResizer() returns the same instance.
A config mistake then shows up at the first upload or read. To catch it at startup instead, verify after initialization:
// src/server.ts
await server.init();
await resizer.verify(); // checks the config, the transport's timing and the media model
await server.startServer();
LocalFsStorage writes:
- previews under
rootDir; - private originals under
privateRootDir, which defaults to./var/media-private.
Serve only rootDir, at /media; never serve the private folder. For S3, see drivers.
5. Store the original at upload
import { getResizer } from '@adaptivestone/framework-module-resize';
fileDoc.original = await getResizer().uploadOriginal({
body: buffer, // the uploaded bytes
visibility: 'private',
// namespace: `users/${user.id}`, // optional key prefix; not access control
});
await fileDoc.save();
uploadOriginal() detects the format and dimensions with Sharp, then stores the bytes unchanged under a random name. It returns the value to save as original. It does not create the document or queue any work. Input larger than upload.maxBytes (25 MiB by default), or in a format not listed in upload.formats, throws ResizeOriginalError.
Save original exactly as returned. Its storageRef is the storage driver's own locator: { path, visibility } for local files, { bucket, key } for S3. Never build it by hand.
Sizes and formats
Define a fixed catalog of sizes for each use, and pass the same catalog when you generate and when you read:
// src/mediaSizes.ts
import type { PreviewFormat, SizeInput } from '@adaptivestone/framework-module-resize';
export const thumbnailSizes: SizeInput[] = [{ width: 320, height: 320 }];
export const detailSizes: SizeInput[] = [{ width: 620 }, { fit: true }];
export const previewFormats: PreviewFormat[] = ['webp']; // the examples use one format
| Size | Key | Result for a 2400×1600 original |
|---|---|---|
{ width: 320, height: 320 } | 320x320 | 320×320, cropped from the center |
{ width: 620 } | 620w | about 620×413, aspect ratio kept |
{ height: 400 } | 400h | 600×400, aspect ratio kept |
{ fit: true } | fit | 1800×1200: the whole image inside maxSize (2000×1200), never enlarged |
The catalog is an allowlist. Never pass dimensions from the client into sizes. Map a client choice such as "thumbnail" to your catalog. Otherwise anyone can make your server resize images to any size they like.
Formats are Sharp output format IDs.
- The
formatsconfig defaults to['jpeg', 'webp', 'avif'], and a call can override it withformats. - Each format is a separate file, so three sizes in three formats make nine files per image.
- Use
'jpeg', not'jpg'. JPEG previews put transparent areas on a white background. - Other Sharp formats, such as
'png'or'tiff', work once you add them to bothformatsandencode.formats. - SVG is accepted only as an original, and its previews are always raster images.
Generate now (eager)
import { getResizer } from '@adaptivestone/framework-module-resize';
import { previewFormats, thumbnailSizes } from './mediaSizes.ts';
const { created, failed } = await getResizer().generate({
media: fileDoc,
sizes: thumbnailSizes,
formats: previewFormats,
});
generate() downloads the original, resizes it, uploads the previews and adds their metadata to previews[], all before it returns. The metadata goes both into MongoDB and onto fileDoc, so don't add it again. No queue or worker is needed.
| Field | Meaning |
|---|---|
created | Preview[]: the files this call made. Previews that already existed are skipped and not listed. |
failed | The number of variants that failed. failed > 0 is a partial success: the successful previews are saved. |
generate() throws ResizeGenerateError if every variant fails, and ResizeNoOriginalError if the media has no original. So { created: [], failed: 0 } means nothing new was needed. failed is only a count; the logs say which variant failed and why.
With persist: false, the files are uploaded but their metadata is not saved, so store created yourself. Eager calls take no locks, so two concurrent calls for the same media can both generate the same variants.
Read URLs
import { formatPictureUrls, getResizer } from '@adaptivestone/framework-module-resize';
const { decision } = await getResizer().resolve({
media: fileDoc,
sizes: thumbnailSizes,
formats: previewFormats,
});
const picture = formatPictureUrls(decision, { id: String(fileDoc.id) });
// { id: '65f0…', sizes: { '320x320': { webp: { url: '/media/previews/8d0e2b7c.webp', contentType: 'image/webp' } } } }
resolve() never resizes and never throws. It returns:
| Field | Meaning |
|---|---|
decision.ready | Variants that can be served now, each with sizeKey, format, url and contentType |
decision.missing | Variants that cannot be served yet; they have no URL |
output | The return value of your formatPublicUrls hook, or undefined if you have none |
formatPictureUrls() leaves missing sizes out of its map, so show your own placeholder for them.
- With a queue configured:
resolve()also queues the missing variants. PassenqueueMissing: falseto only read. - Without a queue: it only reads.
- New previews:
resolve()never waits for the worker. To see new previews, load the media again on a later request.
formatPictureUrls() also skips variants that have filters; build the response from decision yourself for those. To get your own response shape directly, register a hook once: getResizer().hook('formatPublicUrls', (decision) => toMyDto(decision)). output then holds what that hook returns.
On list pages, select only the module's fields and resolve one bounded page:
import { resizeMediaPaths } from '@adaptivestone/framework-module-resize';
const files = await File.find(query).select([...resizeMediaPaths, 'name']).limit(20).lean();
const pictures = [];
for (const file of files) {
const { decision } = await getResizer().resolve({ media: file, sizes: thumbnailSizes, formats: previewFormats });
pictures.push(formatPictureUrls(decision, { id: String(file._id) }));
}
resolve() uses only the fields you loaded. In lazy mode every read can write to the queue, so keep pages bounded.
Generate in the background
Add the queue and the worker
-
Run the scaffold without
--eager. It addssrc/models/ResizeTask.ts(the queue's model) andsrc/commands/ResizeWorker.ts(the worker command), and keeps your existing files:npm exec --package=@adaptivestone/framework-module-resize -- resize-scaffold -
Give the Resizer a transport, which is the queue driver:
// src/resizer.tsimport {createFrameworkMongoTransport,createFrameworkResizer,} from '@adaptivestone/framework-module-resize/framework.js';import { LocalFsStorage } from '@adaptivestone/framework-module-resize/drivers/fs.js';export const resizer = createFrameworkResizer({transport: createFrameworkMongoTransport(), // tasks in MongoDB; timing from config.queuestorage: new LocalFsStorage({ rootDir: './var/media', publicBaseUrl: '/media' }),}); -
Allow the worker to run by setting
worker: { ...defaultFrameworkResizeConfig.worker, enabled: true }insrc/config/resize.ts. This only permits the worker command; the API never starts a worker. -
Create the indexes.
ResizeTaskand the framework'sLockmodel declare their indexes. Create them through your normal migration or deployment process before you deploy the API and the worker. The module never creates indexes at runtime. Mongo's duplicate detection depends on the partial unique index on active tasks. -
Start the worker as a separate long-running process, kept running by your process manager:
npm run cli ResizeWorker
The scaffolded command imports src/resizer.ts, so the worker has the same Resizers as the API:
// src/commands/ResizeWorker.ts (scaffolded)
import '../resizer.ts';
export { ResizeWorker as default } from '@adaptivestone/framework-module-resize/framework.js';
Keep that import if you edit the command; resize-scaffold --check reports a command without it. The API and the worker must use the same database and the same storage. With LocalFsStorage that means the same filesystem; for workers on other machines, use S3 or other shared storage.
Lazy and pre-warm
Lazy needs no extra code: resolve() queues whatever is missing. Pre-warm queues the catalog right after the upload is saved, so the worker usually finishes before the first reader arrives:
const result = await getResizer().prewarm({
media: fileDoc,
sizes: thumbnailSizes,
formats: previewFormats,
});
if (result.status === 'incomplete') {
// result.unconfirmed: variants without a confirmed task
// result.issues: why, and whether a retry can help (issue.retryable)
}
prewarm() never throws, and it reports every requested variant. All variants for one media go into one task.
status | Meaning |
|---|---|
'ready' | Nothing needs queueing, and at least one requested variant is already stored |
'accepted' | Every variant that needs queueing has a confirmed task (result.tasks) |
'not-required' | Nothing to do: the request was empty, or the beforeEnqueue hook removed everything |
'incomplete' | At least one variant has no confirmed task, for example because there is no transport or no original |
The arrays ready, accepted, notRequired and unconfirmed split the requested catalog, and result.tasks holds the task receipts. Sometimes another request is queueing the same variant at the same moment. With Mongo, prewarm() confirms that variant by finding the other request's active task. SQS cannot look tasks up, so such a variant comes back incomplete and retryable. An unexpected error, for example a media document without an ID, is incomplete with a RESIZE_ENQUEUE_INTERNAL_ERROR issue.
Queues and workers
Every task waits in a named queue. When you don't name one, it is 'default'.
| You want… | How |
|---|---|
| A Resizer's tasks on another queue | createFrameworkResizer({ …, queue: 'bulk' }) |
| One call's tasks on another queue | prewarm({ …, queue: 'bulk' }); resolve() also accepts queue |
A worker for 'default' | npm run cli ResizeWorker |
| A worker for another queue | npm run cli ResizeWorker -- --queue=bulk |
A worker consumes exactly one queue. A common setup keeps uploads and reads on 'default' and runs a large backfill on 'bulk' with its own worker, so the backfill never delays new uploads.
Any number of workers, on any number of servers, can consume one queue; each task is processed by one worker at a time. If a worker dies, its lease (its time-limited hold on the task) expires and another worker takes the task. A task can therefore run more than once, but the worker skips previews that already exist.
More than one Resizer
Most apps need one Resizer. Create more when parts of the app need different storage, media models or formats, for example avatars and listings:
// src/resizer.ts
const transport = createFrameworkMongoTransport();
export const resizer = createFrameworkResizer({ transport, storage: avatarStorage }); // 'default'
export const listings = createFrameworkResizer({
name: 'listings',
configName: 'resizeListings', // src/config/resizeListings.ts, a complete config like resize.ts
transport, // may share the transport, or have its own
storage: listingStorage,
});
// elsewhere: getResizer('listings').resolve({ … })
- One construction per name: each name can be created only once per process.
- Create them all in
src/resizer.ts: every task records which Resizer created it, and the worker gives the task to the Resizer with that name, so the worker needs all of them. Creating them insrc/resizer.tsgives both the API and the worker the same set. - Transports: Resizers may share a transport or each have their own. The worker runs one loop per transport for its queue.
- No mixing: previews from different Resizers never mix.
- Worker settings: the worker reads its own settings (
worker.enabledand the Sharp tuning) fromsrc/config/resize.ts.
Pipelines, filters and hooks
If you register nothing, the module only resizes and encodes. For custom image processing, register a pipeline in src/resizer.ts, so the worker has it too:
export const resizer = createFrameworkResizer({
storage,
pipelines: {
photo: {
beforeSteps: [], // run once per task on the source image, e.g. blur faces or plates
variantSteps: [ // run per variant, after resize and before encode
(img, { variant }) => (variant.filters?.blur ? img.blur(Number(variant.filters.blur)) : img),
],
},
},
});
// A read that asks for that rendering:
await getResizer().resolve({
media: fileDoc,
pipeline: 'photo',
sizes: [{ width: 320, height: 320, filters: { blur: 40 } }],
});
- Watermarks go in
variantSteps. InbeforeSteps, the watermark is applied to the original and shrinks until it is unreadable in small previews. - A filter such as
{ blur: 40 }does nothing by itself. YourvariantStepsgive it meaning. ctxdoes not reach the worker. Queued tasks carry only the pipeline name and the variants, and steps in the worker receivectx === {}. Keep any data a step needs on the media document. Only eagergenerate()passes yourctxto the steps.- Each pipeline keeps its own previews. A preview is identified by Resizer, pipeline, size, format and filters. Changing a pipeline's code does not regenerate existing previews; give the pipeline a new name, such as
photo-v2, to do that. An unknown pipeline name runs no steps. - Adding pipelines later:
getResizer().registerPipeline(name, pipeline)adds or replaces one after construction.
Hooks customize inputs and responses, or observe events. Register them with hooks: at construction or with getResizer().hook(name, fn). TypeScript infers each signature from the hook name.
| Hook | Runs | Returns |
|---|---|---|
resolveSizes | generate(), prewarm(), resolve() | The sizes to use |
beforeEnqueue | prewarm() and resolve(), before queueing | The missing variants to keep |
formatPublicUrls | resolve() | The value returned as output |
onPreviewGenerated | After each new preview is saved | Nothing |
afterTaskComplete | After a queued task stored every variant | Nothing |
onTaskFailed | After a failed attempt that will be retried | Nothing |
onTaskDeadLettered | After a Mongo task fails for the last time | Nothing |
Hook functions run in the order they were registered, and each one is awaited. A function that throws is logged and skipped. The framework event bus also receives the four observer hooks (the last four rows) as resize:<hookName>.
Drivers
| Option | Shipped drivers | Import from |
|---|---|---|
storage (required) | LocalFsStorage, S3Storage | …/drivers/fs.js, …/drivers/s3.js |
transport (queue only) | Mongo via createFrameworkMongoTransport(), SqsTransport | …/framework.js, …/drivers/sqs.js |
mediaStore | FrameworkMediaStore, added by createFrameworkResizer | …/framework.js |
The transport owns its locks (locks, a LockStore), because locks exist only for queued work. createFrameworkMongoTransport() uses FrameworkLockStore, which wraps the framework's own Lock model. The framework drivers are thin wrappers over framework-free ones in …/drivers/mongo.js: FrameworkMediaStore is MongoMediaStore with the model taken from the app.
S3 needs npm i @aws-sdk/client-s3 @aws-sdk/s3-request-presigner:
import { S3Storage } from '@adaptivestone/framework-module-resize/drivers/s3.js';
const storage = new S3Storage({
bucketPublic: 'my-cdn', // previews
bucketPrivate: 'my-originals', // originals; must differ from bucketPublic
publicBaseUrl: 'https://cdn.example.com',
// client: an existing S3Client; otherwise the AWS SDK's default credentials and region
});
You create the buckets and their access policies; publicBaseUrl only builds URLs. The driver reads only from these two buckets, so a tampered storageRef cannot reach another bucket.
SQS needs npm i @aws-sdk/client-sqs sqs-consumer:
import { appLogger, FrameworkLockStore } from '@adaptivestone/framework-module-resize/framework.js';
import { SqsTransport } from '@adaptivestone/framework-module-resize/drivers/sqs.js';
const transport = new SqsTransport({
queueUrl: 'https://sqs.eu-west-1.amazonaws.com/123456789012/resize', // queue 'default'
queues: { bulk: 'https://sqs.eu-west-1.amazonaws.com/123456789012/resize-bulk' }, // optional
region: 'eu-west-1',
locks: new FrameworkLockStore(), // SQS has no locks of its own
logger: appLogger,
});
SQS needs no ResizeTask model. Retries and dead-lettering come from the queue's redrive policy: set maxReceiveCount to queue.maxAttempts. onTaskDeadLettered does not fire for SQS.
Every kind of driver has an exported abstract class: ResizeStorage, MediaStore, QueueTransport and LockStore. A custom driver extends one (for example class PostgresMediaStore extends MediaStore), or is any object of the same shape. Drivers receive no app argument; each one uses its own clients. The package reference lists every option and contract.
Originals, SVG and private access
Previews are public. Store originals privately (visibility: 'private'). Your app decides who may upload, read and delete media.
-
SVG is untrusted. An SVG file can contain scripts and external references, so the module never serves SVG markup, not even to its owner.
uploadOriginal()accepts SVG only withvisibility: 'private'.- The generator renders it into your raster formats and never loads anything the file references.
- The stored file is not cleaned, so never serve it yourself.
-
Small raster originals can be served directly. This happens only when all of these are true:
- no preview exists yet;
- the request has both
widthandheightand no filters; - the original already fits inside that box.
Width-only, height-only and
fitsizes never use this shortcut, and pipeline steps do not run on it. Such entries haveisOriginal: true. Use theircontentType, which is the original's type, rather thanformat. -
A private original is returned only as a signed URL. For the shortcut above, the module signs a URL that is valid for five minutes, and only when your server sets
ctx.isOwnerorctx.isAdminon the read. Never take these flags from client input. If signing fails, there is no fallback to a public URL. Custom storage drivers can implementcanServeOriginalPubliclyto report public originals.
Without the framework
The core and the shipped drivers contain no framework code, and the framework is an optional peer dependency. A plain Node app with MongoDB uses the Mongo drivers and writes no driver code:
import mongoose from 'mongoose';
import { Resizer, runWorker } from '@adaptivestone/framework-module-resize';
import defaultResizeConfig from '@adaptivestone/framework-module-resize/config/resize.js';
import { LocalFsStorage } from '@adaptivestone/framework-module-resize/drivers/fs.js';
import {
createResizeModels, MongoLockStore, MongoMediaStore, MongoTransport,
} from '@adaptivestone/framework-module-resize/drivers/mongo.js';
const { ResizeTask, ResizeLock } = createResizeModels(mongoose.connection);
const resizer = new Resizer({
config: { ...defaultResizeConfig, formats: ['webp'] },
storage: new LocalFsStorage({ rootDir: './var/media', publicBaseUrl: '/media' }),
mediaStore: new MongoMediaStore({ model: File }), // File spreads resizeMediaSchemaFragment
transport: new MongoTransport({ // optional: queued workflows only
model: ResizeTask,
locks: new MongoLockStore({ model: ResizeLock }),
}),
});
// In the worker process:
await runWorker({ signal: shutdown.signal, queue: 'default' });
createResizeModels(connection)registersResizeTask(the queue) andResizeLockwith the package's schemas and indexes, and turnsautoIndexoff: create the indexes through your migration process (for exampleResizeTask.createIndexes()).configis optional and holds image settings only. Queue timing (leaseMs,lockTtlMs,maxAttempts, …) is aMongoTransportoption, and Sharp tuning isrunWorker({ sharp: { concurrency, cache } }).- The framework adapter (
…/framework.js) does exactly this wiring for you.
Configuration
src/config/resize.ts spreads defaultFrameworkResizeConfig and adds mediaModelName plus your changes. The framework merges resize.<NODE_ENV>.ts over it: objects merge field by field, and arrays are replaced. The image settings go to the Resizer; the adapter reads mediaModelName, queue (the Mongo transport's timing and lock TTLs) and worker (the worker command). queue and worker may be omitted, and then the defaults apply.
| Option | Default | Meaning |
|---|---|---|
mediaModelName | required | Your media model's name |
formats | ['jpeg', 'webp', 'avif'] | Formats generated when a call doesn't pass formats; each needs an encode.formats entry |
upload.maxBytes | 25 MiB | Largest original uploadOriginal() accepts |
upload.formats | jpeg, png, webp, avif, gif, svg | Original formats uploadOriginal() accepts |
maxSize | { width: 2000, height: 1200 } | The box for fit |
encode.formats | JPEG quality 80, WebP 82, AVIF 64 | Sharp encoder options per format; {} keeps Sharp's defaults |
concurrency | 4 | Variants processed in parallel per task or generate() call |
worker.enabled | false | Allows the worker command to run |
queue.maxAttempts | 5 | Attempts before a Mongo task is dead-lettered |
queue.leaseMs, queue.lockTtlMs | 60000, { dispatch: 60000, worker: 60000 } | The task lease and lock TTLs; the worker lock must not outlive the lease |
To change one encoder setting, spread the nested defaults:
encode: {
...defaultFrameworkResizeConfig.encode,
formats: { ...defaultFrameworkResizeConfig.encode.formats, avif: { quality: 55, effort: 4 } },
},
The full config reference covers limits, sharpening, animation and queue timing. Keys renamed since 0.2 fail with RESIZE_CONFIG_REMOVED_KEY:
| 0.2 key | Use instead |
|---|---|
webpAvifOnly: true | formats: ['webp', 'avif'] |
encode.quality.<format>, encode.effort.<format> | encode.formats.<format>.quality, encode.formats.<format>.effort |
encode.mozjpeg, encode.chromaSubsampling | encode.formats.jpeg.mozjpeg, encode.formats.jpeg.chromaSubsampling |
encode.flattenBackground | encode.flatten.background |
Errors
resolve() and prewarm() never throw: they log the failure and return a safe result (prewarm() reports it as an issue). Every other error from the module extends ResizeError and carries a stable code:
| Error | Meaning | What to do |
|---|---|---|
ResizeSetupError | The wiring is wrong, e.g. a duplicate Resizer name | Fix the code |
ResizeConfigError | The config is invalid or incomplete | Fix the config; resizer.verify() reports it at startup |
ResizeOriginalError | Uploaded bytes are invalid, unsupported or too large | Reject the upload |
ResizeNoOriginalError | The media has no original | Upload the original first |
ResizeMediaError | The media can't be used; the two errors above extend it | Skip that media |
ResizeGenerateError | generate() made nothing, or a queued task left variants missing | Check failed, requested, missing and the logs |
ResizeStorageError | A storage operation failed | A retry may help |
ResizeSecurityError | A refused storage access, such as path traversal | Never retry; investigate |
import { ResizeError, ResizeNoOriginalError } from '@adaptivestone/framework-module-resize';
try {
await getResizer().generate({ media: fileDoc, sizes: thumbnailSizes });
} catch (err) {
if (err instanceof ResizeNoOriginalError) return badRequest('Upload the image first');
if (ResizeError.isResizeError(err)) return badRequest(err.message);
throw err; // not from this module
}
If two copies of the package are installed, instanceof can fail across them. ResizeError.isResizeError(err) still works in that case.
Operations and troubleshooting
- Task states. Mongo tasks go
pending → processing → completed. A failed attempt returns topendingwith a growing delay. Afterqueue.maxAttempts, the task becomesdead. - Partial attempts. A task completes only when every requested variant is stored. When an attempt is only partly successful, its previews are saved and the next attempt makes only the missing ones.
- Cleanup and retries. Completed tasks are removed after about 24 hours and dead tasks after about 30 days. To retry a dead task after fixing its cause, call
prewarm()again for that media. - Throughput. A Mongo worker processes one task at a time, and
concurrencyparallelizes the variants within it. For more throughput, run more worker processes.
| Symptom | Check |
|---|---|
| Worker exits with "disabled" | worker.enabled: true in src/config/resize.ts |
Worker stops at start with RESIZE_NO_RESIZER | src/commands/ResizeWorker.ts must import ../resizer.ts; delete the old file and run resize-scaffold again |
Worker logs RESIZE_NO_RESIZER for a task | That Resizer must be created in src/resizer.ts |
Worker stops with RESIZE_CONFIG_MEDIA_MODEL_UNKNOWN | mediaModelName must name a registered model |
Tasks stay pending | Is a worker running for that queue (--queue), on the same database? |
| Variants are missing but there are no tasks | enqueueMissing, the original's storageRef, the indexes, hooks and logs; prewarm() reports the reason per variant |
| Tasks fail repeatedly | Original access, image limits, pipeline code and worker logs; RESIZE_WORKER_INCOMPLETE lists the missing variants |
previews[] has entries but the response is empty | The query projection (resizeMediaPaths), stale caches, or how you map decision |
| A returned URL gives 404 or 403 | Static-file, CDN or bucket access; the module doesn't check whether the file exists |
Your app owns replacing and deleting media and their storage files (the module only adds previews), access checks, and refreshing cached responses.