npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@adaptivestone/framework-module-resize

v0.1.0

Published

Adaptive stone node js framework module: lazy image resize (sharp)

Readme

@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