{"_id":"@adaptivestone/framework-module-resize","_rev":"3-d28edcc609fd05e0fa11c51aa74efffc","name":"@adaptivestone/framework-module-resize","dist-tags":{"latest":"0.2.1"},"versions":{"0.1.0":{"name":"@adaptivestone/framework-module-resize","version":"0.1.0","keywords":["node","image","resize","sharp","framework","adaptivestone","framework-module-resize"],"author":{"name":"Andrei Lahunou"},"license":"MIT","_id":"@adaptivestone/framework-module-resize@0.1.0","maintainers":[{"name":"systerr","email":"systerr@gmail.com"}],"homepage":"https://framework.adaptivestone.com/docs/resize","bugs":{"url":"https://github.com/adaptivestone/framework-module-resize/issues"},"bin":{"resize-scaffold":"dist/scaffold/command.js"},"dist":{"shasum":"8afdd9eb52c6f65524c25304b3a966517fd1f656","tarball":"https://registry.npmjs.org/@adaptivestone/framework-module-resize/-/framework-module-resize-0.1.0.tgz","fileCount":62,"integrity":"sha512-TXjFQ4Q3IRaFT8AEvxQEMIb3PuVEPJNShqrEkVcQx0QXsF9nMzcaxBS5h5r4x5UGqvVYvXqwpGMmOr6ozSN0rw==","signatures":[{"sig":"MEUCIQDS9QwD3HbbmzfC/BNGkGjmSsNQifdinUbiFjLeefe6CQIgcrrI46ehW37gYsded2Ttht3AEY+YFTtFqRSTop+m2lE=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":194134},"main":"./dist/index.js","type":"module","types":"./dist/index.d.ts","engines":{"node":">=24.0.0"},"exports":{".":"./dist/index.js","./package.json":"./package.json","./storage/s3.js":"./dist/storage/s3.js","./config/resize.js":"./dist/config/resize.js","./transports/sqs.js":"./dist/transports/sqs.js","./locks/framework.js":"./dist/locks/framework.js","./transports/mongo.js":"./dist/transports/mongo.js","./models/ResizeTask.js":"./dist/models/ResizeTask.js","./mediaStore/framework.js":"./dist/mediaStore/framework.js","./commands/ResizeWorker.js":"./dist/commands/ResizeWorker.js"},"gitHead":"5609217e42563c4c1b734a7118fa527ae6cafb03","scripts":{"test":"node --experimental-strip-types --test","build":"node preBuild.ts && tsc && node postBuild.ts","check":"biome check","smoke":"node smokeTest.ts","check:fix":"biome check --write","types:check":"tsc --noEmit","prepublishOnly":"npm run build"},"_npmUser":{"name":"systerr","email":"systerr@gmail.com"},"repository":{"url":"git+https://github.com/adaptivestone/framework-module-resize.git","type":"git"},"_npmVersion":"11.16.0","description":"Adaptive stone node js framework module: lazy image resize (sharp)","directories":{},"_nodeVersion":"26.3.1","dependencies":{"sharp":"^0.35.3","deepmerge":"^4.3.1"},"_hasShrinkwrap":false,"devDependencies":{"mongoose":"^9.0.0","typescript":"7.0.1-rc","@types/node":"^26.1.0","sqs-consumer":"^15.0.2","@biomejs/biome":"^2.5.2","@types/express":"^5.0.6","@aws-sdk/client-s3":"^3.0.0","@aws-sdk/client-sqs":"^3.1079.0","mongodb-memory-server":"^11.0.0","@adaptivestone/framework":"^5.1.0","@aws-sdk/s3-request-presigner":"^3.0.0"},"peerDependencies":{"mongoose":"*","sqs-consumer":"*","@aws-sdk/client-s3":"*","@aws-sdk/client-sqs":"*","@adaptivestone/framework":"^5.0.1","@aws-sdk/s3-request-presigner":"*"},"peerDependenciesMeta":{"sqs-consumer":{"optional":true},"@aws-sdk/client-s3":{"optional":true},"@aws-sdk/client-sqs":{"optional":true},"@aws-sdk/s3-request-presigner":{"optional":true}},"_npmOperationalInternal":{"tmp":"tmp/framework-module-resize_0.1.0_1783254948167_0.15264943454783086","host":"s3://npm-registry-packages-npm-production"}},"0.2.0":{"name":"@adaptivestone/framework-module-resize","version":"0.2.0","keywords":["node","image","resize","sharp","framework","adaptivestone","framework-module-resize"],"author":{"name":"Andrei Lahunou"},"license":"MIT","_id":"@adaptivestone/framework-module-resize@0.2.0","maintainers":[{"name":"systerr","email":"systerr@gmail.com"}],"homepage":"https://framework.adaptivestone.com/docs/resize","bugs":{"url":"https://github.com/adaptivestone/framework-module-resize/issues"},"bin":{"resize-scaffold":"dist/scaffold/command.js"},"dist":{"shasum":"c5ace01999f2ff4a0da6f1d69459acb449fc7e01","tarball":"https://registry.npmjs.org/@adaptivestone/framework-module-resize/-/framework-module-resize-0.2.0.tgz","fileCount":69,"integrity":"sha512-KrKA791z82Yfq9NXATXDEDnwjLsw593SG2v+66mRcxEw18QswJSNj5ZJElLkB8zRLUHMB/UGGRl8df9lRgtlLg==","signatures":[{"sig":"MEUCIQCRWx2jY0F748+879ZrwvNJ6b9WT4ky9Q3kh4RskTNXnAIgHi5NbxJ7NvjToL4UKtmyfXUh5/XPXSE1ln04zxTldsg=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":218151},"main":"./dist/index.js","type":"module","types":"./dist/index.d.ts","engines":{"node":">=24.0.0"},"exports":{".":"./dist/index.js","./package.json":"./package.json","./storage/fs.js":"./dist/storage/fs.js","./storage/s3.js":"./dist/storage/s3.js","./config/resize.js":"./dist/config/resize.js","./transports/sqs.js":"./dist/transports/sqs.js","./locks/framework.js":"./dist/locks/framework.js","./transports/mongo.js":"./dist/transports/mongo.js","./models/ResizeTask.js":"./dist/models/ResizeTask.js","./mediaStore/framework.js":"./dist/mediaStore/framework.js","./commands/ResizeWorker.js":"./dist/commands/ResizeWorker.js"},"gitHead":"7071d4fa90b974b380775005135364f55c7978f9","scripts":{"test":"node --experimental-strip-types --test","build":"node preBuild.ts && tsc && node postBuild.ts","check":"biome check","smoke":"node smokeTest.ts","check:fix":"biome check --write","types:check":"tsc --noEmit","prepublishOnly":"npm run build"},"_npmUser":{"name":"systerr","email":"systerr@gmail.com"},"repository":{"url":"git+https://github.com/adaptivestone/framework-module-resize.git","type":"git"},"_npmVersion":"12.0.2","description":"Adaptive stone node js framework module: image resize (sharp)","directories":{},"_nodeVersion":"26.7.0","dependencies":{"sharp":"^0.35.3","deepmerge":"^4.3.1"},"_hasShrinkwrap":false,"devDependencies":{"mongoose":"^9.0.0","typescript":"^7.0.2","@types/node":"^26.1.0","sqs-consumer":"^15.0.2","@biomejs/biome":"^2.5.2","@types/express":"^5.0.6","@aws-sdk/client-s3":"^3.0.0","@aws-sdk/client-sqs":"^3.1079.0","mongodb-memory-server":"^11.0.0","@adaptivestone/framework":"^5.1.0","@aws-sdk/s3-request-presigner":"^3.0.0"},"peerDependencies":{"mongoose":"*","sqs-consumer":"*","@aws-sdk/client-s3":"*","@aws-sdk/client-sqs":"*","@adaptivestone/framework":"^5.0.1","@aws-sdk/s3-request-presigner":"*"},"peerDependenciesMeta":{"sqs-consumer":{"optional":true},"@aws-sdk/client-s3":{"optional":true},"@aws-sdk/client-sqs":{"optional":true},"@aws-sdk/s3-request-presigner":{"optional":true}},"_npmOperationalInternal":{"tmp":"tmp/framework-module-resize_0.2.0_1786825412748_0.8483143440568344","host":"s3://npm-registry-packages-npm-production"}},"0.2.1":{"_id":"@adaptivestone/framework-module-resize@0.2.1","bin":{"resize-scaffold":"dist/scaffold/command.js"},"bugs":{"url":"https://github.com/adaptivestone/framework-module-resize/issues"},"dist":{"shasum":"54e8dd7ced96a64bb9c695dbf7b755acc75de381","tarball":"https://registry.npmjs.org/@adaptivestone/framework-module-resize/-/framework-module-resize-0.2.1.tgz","fileCount":69,"integrity":"sha512-Cz0+tSnRGQ2I0MnbYkGNHWSKVJvEsamLKwUaST/zfymRCeGRRjE4FkuLnT0qaAtBMuyo7GfRhoN6CPnwjS5tzg==","signatures":[{"sig":"MEUCIDiAmTZ7arjouyuikSzQ6zCqwR/DyGBDR1rLdCHOST99AiEAh5i/jWuZY2mZ/B4GLtJP5Ue+43Ki4bIgqIjCGaZtJno=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"},{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEQCIC/ZRllr9TsXOECC5+dMlNOGfZJjFK4L4X1msDbmr241AiA0WOBzZp1m54aFhAKAbxP/7/S9tLIGrtbO2TordbaXPQ=="}],"unpackedSize":235686},"main":"./dist/index.js","name":"@adaptivestone/framework-module-resize","type":"module","types":"./dist/index.d.ts","author":{"name":"Andrei Lahunou"},"engines":{"node":">=24.0.0"},"exports":{".":"./dist/index.js","./package.json":"./package.json","./storage/fs.js":"./dist/storage/fs.js","./storage/s3.js":"./dist/storage/s3.js","./config/resize.js":"./dist/config/resize.js","./transports/sqs.js":"./dist/transports/sqs.js","./locks/framework.js":"./dist/locks/framework.js","./transports/mongo.js":"./dist/transports/mongo.js","./models/ResizeTask.js":"./dist/models/ResizeTask.js","./mediaStore/framework.js":"./dist/mediaStore/framework.js","./commands/ResizeWorker.js":"./dist/commands/ResizeWorker.js"},"gitHead":"68ea1016788bfe15762d6271300992379f776624","license":"MIT","scripts":{"test":"node --experimental-strip-types --test","build":"node preBuild.ts && tsc && node postBuild.ts","check":"biome check","smoke":"node smokeTest.ts","check:fix":"biome check --write","types:check":"tsc --noEmit","prepublishOnly":"npm run build"},"version":"0.2.1","_npmUser":{"name":"systerr","email":"systerr@gmail.com"},"homepage":"https://framework.adaptivestone.com/docs/resize","keywords":["node","image","resize","sharp","framework","adaptivestone","framework-module-resize"],"repository":{"url":"git+https://github.com/adaptivestone/framework-module-resize.git","type":"git"},"_npmVersion":"12.0.2","description":"Adaptive stone node js framework module: image resize (sharp)","directories":{},"maintainers":[{"name":"systerr","email":"systerr@gmail.com"}],"_nodeVersion":"26.8.1","dependencies":{"sharp":"^0.35.3","deepmerge":"^4.3.1"},"_hasShrinkwrap":false,"devDependencies":{"mongoose":"^9.0.0","typescript":"^7.0.2","@types/node":"^26.1.0","sqs-consumer":"^15.0.2","@biomejs/biome":"^2.5.2","@types/express":"^5.0.6","@aws-sdk/client-s3":"^3.0.0","@aws-sdk/client-sqs":"^3.1079.0","mongodb-memory-server":"^11.0.0","@adaptivestone/framework":"^5.1.0","@aws-sdk/s3-request-presigner":"^3.0.0"},"peerDependencies":{"mongoose":"*","sqs-consumer":"*","@aws-sdk/client-s3":"*","@aws-sdk/client-sqs":"*","@adaptivestone/framework":"^5.0.1","@aws-sdk/s3-request-presigner":"*"},"peerDependenciesMeta":{"sqs-consumer":{"optional":true},"@aws-sdk/client-s3":{"optional":true},"@aws-sdk/client-sqs":{"optional":true},"@aws-sdk/s3-request-presigner":{"optional":true}},"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/framework-module-resize_0.2.1_1789144464499_0.6663796180578219"}}},"time":{"created":"2026-07-05T12:35:47.947Z","modified":"2026-09-11T16:34:24.836Z","0.1.0":"2026-07-05T12:35:48.346Z","0.2.0":"2026-08-15T20:23:32.922Z","0.2.1":"2026-09-11T16:34:24.634Z"},"bugs":{"url":"https://github.com/adaptivestone/framework-module-resize/issues"},"author":{"name":"Andrei Lahunou"},"license":"MIT","homepage":"https://framework.adaptivestone.com/docs/resize","keywords":["node","image","resize","sharp","framework","adaptivestone","framework-module-resize"],"repository":{"url":"git+https://github.com/adaptivestone/framework-module-resize.git","type":"git"},"description":"Adaptive stone node js framework module: image resize (sharp)","maintainers":[{"name":"systerr","email":"systerr@gmail.com"}],"readme":"# @adaptivestone/framework-module-resize\n\nImage resizing for [`@adaptivestone/framework`](https://framework.adaptivestone.com).\nUpload only the **original**; generate resized variants with [`sharp`](https://sharp.pixelplumbing.com).\n**Eager** mode (`generate` at upload) is a complete first-class path — no queue, no worker.\nLazy and pre-warm share the same core and the same `previews[]` when listings get huge.\n\nEverything the module touches — storage, the optional queue transport, the media store, the\nlock provider — is a **swappable driver** wired in one constructor literal. The core owns\nonly the identity, the read decision, and the resize pipeline.\n\nDistilled from several prior production implementations of upload-time resizing, minus their\nsynchronous all-or-nothing cost and their three incompatible response shapes.\n\n> **Coding agents** (Claude Code, Cursor, Codex, …): read [`AGENTS.md`](./AGENTS.md) — the\n> machine-oriented integration guide that ships with this package.\n\n---\n\n## How it works\n\n```\nupload ─▶ store the ORIGINAL ─▶ generate({ media, sizes }) ─▶ previews[] on the media doc\n  read ─▶ resolve({ media, sizes }) ─▶ ready URLs (or missing, if you skipped generate)\n```\n\nEager is the default story. `generate` runs the same sharp core inline at upload and appends\nonto both the store and the in-memory `media.previews`, so a same-request `resolve({ media })`\nsees the new rows.\n\nWhen listings are huge, skip `generate` and add a transport + worker: `resolve` enqueues\nmissing variants instead. `sharp` stays off the HTTP **read** path either way.\n\n---\n\n## Install\n\n```bash\nnpm i @adaptivestone/framework-module-resize\n```\n\nRequires Node `>=24` and the framework/mongoose peers (mandatory — a nested second framework copy\nbreaks the model loader). The AWS drivers are **optional peers**: install them only for the driver\nyou use. Each optional peer is resolved **only** when you import its driver subpath — the main\nentry never loads the AWS SDKs, and a missing peer fails loudly at your own import line at\nbootstrap, not at first I/O.\n\n| You use… | Also install |\n|---|---|\n| **SQS transport** (`/transports/sqs.js`) | `@aws-sdk/client-sqs` `sqs-consumer` |\n| **S3 storage** (`/storage/s3.js`) | `@aws-sdk/client-s3` `@aws-sdk/s3-request-presigner` |\n| **Local filesystem** (`/storage/fs.js`) / Mongo transport / framework media store / locks | nothing (no optional deps) |\n\n### Scaffold the integration files\n\nThe framework discovers models and commands by scanning your `src/` folder, so a few thin files\nmust live in your app. Generate them once:\n\n```bash\nnpx @adaptivestone/framework-module-resize resize-scaffold --eager\n```\n\nIt emits (into `process.cwd()`, or `--out <dir>`), **never overwriting** without `--force`:\n\n| File | What it is |\n|---|---|\n| `src/resizer.ts` | the construction site — `new Resizer({ … })` (edit freely) |\n| `src/config/resize.ts` | editable config that spreads the module defaults |\n| `src/models/ResizeTask.ts` | thin shim (only without `--eager`) |\n| `src/commands/ResizeWorker.ts` | worker command re-export (only without `--eager`) |\n\nThe shims are **not vendored copies** — the schema/behavior stays in the npm package (auto-updates,\nno drift). `--eager` wires `LocalFsStorage`; omit the flag for a Mongo transport + a storage TODO.\n\nOther flags: `--check` (CI-gatable drift check; exits 1 on missing/drift, no writes), `--eject`\n(write the full editable model instead of the shim, for custom fields/indexes), `--agents\n<agents|claude|print|skip>` (where to write the append-only, marker-idempotent pointer to the\nshipped [`AGENTS.md`](./AGENTS.md); default `agents` = the host `AGENTS.md`), `--force`,\n`--out <dir>`.\n\n---\n\n## Quick start (eager + local filesystem)\n\nStart here. No queue, no worker, no AWS. `npx resize-scaffold --eager` emits this wiring.\n\n**1. Wire the Resizer** after `Server.init()` (or lazily on first request). One Resizer per\nprocess — a second `new Resizer()` throws.\n\n```ts\nimport { Resizer } from '@adaptivestone/framework-module-resize';\nimport { LocalFsStorage } from '@adaptivestone/framework-module-resize/storage/fs.js';\n\nexport const resizer = new Resizer({\n  storage: new LocalFsStorage({ rootDir: './var/media', publicBaseUrl: '/media' }),\n});\n```\n\n**2. At upload**, after the original is on `media.original`:\n\n```ts\nconst { created, failed } = await resizer.generate({\n  media,\n  sizes: [{ width: 320, height: 320 }],\n});\nconst { decision } = await resizer.resolve({\n  media, // generate already appended `created` onto media.previews\n  sizes: [{ width: 320, height: 320 }],\n});\n```\n\n**3. Set your media model name** in `src/config/resize.ts` (the one required field) and spread\n`resizeMediaSchemaFragment` into the model so `original` + `previews[]` exist. Listing queries:\n\n```ts\nimport { resizeMediaPaths } from '@adaptivestone/framework-module-resize';\nFile.find().select(['mediaType', ...resizeMediaPaths]);\n```\n\nS3 when you have buckets; a queue when listings are huge — both are later sections.\n\n---\n\n## When listings are huge (lazy / queue)\n\nAdd a `transport` and run `ResizeWorker`. Missing variants are enqueued on `resolve()` (or\npushed at upload with `prewarm()`).\n\nThe Mongo transport deduplicates identical active tasks: variants are canonicalized and a\nSHA-256 `requestKey` is stored under a partial unique index for `pending`/`processing` rows.\nThe key includes the pipeline and the complete variant payload handed to the transport;\nreordering that payload returns the existing task. Before this stage, shared dispatch locks\ncan remove overlapping variants from a request. Rows created before `requestKey` was introduced\nremain valid.\n\n```ts\n// src/resizer.ts — construct after Server.init(); import from API and worker processes\nimport { Resizer } from '@adaptivestone/framework-module-resize';\nimport { MongoTransport } from '@adaptivestone/framework-module-resize/transports/mongo.js';\nimport { S3Storage } from '@adaptivestone/framework-module-resize/storage/s3.js'; // optional AWS peers resolved only here\n\nexport const resizer = new Resizer({\n  transport: new MongoTransport(),           // or new SqsTransport({ queueUrl, region })\n  storage: new S3Storage({\n    bucketPublic: 'my-cdn',\n    bucketPrivate: 'my-originals',\n    publicBaseUrl: 'https://cdn.example.com',\n    client,                                  // existing S3Client — env/keys stay in the host\n  }),\n  pipelines: {\n    default: {},\n    listing: { beforeSteps: [blurPlates] },\n    premium: {\n      variantSteps: [(img, { variant }) => variant.filters?.blur ? img.blur(Number(variant.filters.blur)) : img],\n    },\n  },\n  hooks: {\n    resolveSizes:     (sizes, ctx) => ctx.entity === 'event' ? [...sizes, { fit: true }] : sizes,\n    formatPublicUrls: (decision, ctx) => toHostDto(decision, ctx),\n  },\n});\n```\n\n**Run the worker** as a separate process (gated by `worker.enabled`):\n\n```bash\nnpm run cli ResizeWorker\n```\n\nYour media model (`File`/`Media`) must carry `original` (incl. `width`/`height`) and `previews[]`\n(incl. `filters`/`fit`). That schema is host-owned; to avoid hand-written drift the module exports\nan **opt-in** `as const` fragment you can spread in (single source of truth for the runtime schema\nand the types):\n\n```ts\nimport { resizeMediaSchemaFragment } from '@adaptivestone/framework-module-resize';\nclass File extends BaseModel {\n  static get modelSchema() { return { ...existingFields, ...resizeMediaSchemaFragment } as const; }\n}\n```\n\nAt **upload** capture `original.width/height` (from sharp metadata) onto the media doc; if you\ndon't, the worker backfills them on first process.\n\n**Read** from your DTO builders. No `app` argument — the module reads the ambient app instance.\n`resolve` returns the raw `decision` and the `output` of your `formatPublicUrls` hook (`undefined`\nwhen there is no hook or the hook throws — the raw decision is never sent as a DTO):\n\n```ts\nimport { resizer } from '../resizer.ts';   // or: getResizer()\n\nconst { output } = await resizer.resolve({\n  media: fileDoc,\n  pipeline: 'listing',\n  sizes: [\n    { width: 1760, height: 990 },\n    { width: 620 },\n    { fit: true },\n    { width: 300, height: 300, filters: { blur: 40 } },\n  ],\n  ctx: { entity: 'event', isOwner },\n});\nreturn output; // your own shape, produced by formatPublicUrls\n```\n\n---\n\n## Modes: lazy vs pre-warm vs eager\n\nAll three modes drive the **same resize core** and write the same `previews[]` shape, so you can\nswitch later with no data migration, or mix them.\n\n| | **Lazy** (queued, on read) | **Pre-warm** (queued, at upload) | **Eager** (sync, at upload) |\n|---|---|---|---|\n| Generate | on first read; `resolve()` enqueues missing | at upload; `prewarm()` enqueues the catalog | inline at upload via `resizer.generate(...)` |\n| Needs | transport + `ResizeWorker` + `ResizeTask` + locks | same as lazy (transport + worker) | storage + media model only — **no** queue/worker |\n| 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 |\n\n> **Start eager.** It is a complete mode: no worker, no queue. **Graduate to lazy or pre-warm**\n> when listings are huge and you want uploads to stay fast. The stored shape is identical, so\n> you can switch later or mix the three.\n\n**Pre-warm** — keep the lazy wiring (transport + worker), but push the catalog into the **queue**\nat upload so the previews are usually ready by the first read: no `sharp` on the request path, no\nwaiting for the first reader. Never blocks and never throws (same guarantee as `resolve`); `ctx`\nreaches the read-path waterfalls (the worker still runs with `ctx === {}`):\n\n```ts\n// upload handler, after the media doc is created:\nawait resizer.prewarm({ media: fileDoc, sizes: getListingSizes(), pipeline: 'listing' });\n// → { enqueued } = how many variants were handed to the queue\n```\n\nChoose pre-warm when you want **fast uploads and a warm cache** — the request returns immediately\nwhile the worker fills the catalog in the background.\n\n**Eager** — call `generate` from your\nupload handler (`ctx` reaches pipeline steps here, unlike the queued worker):\n\n```ts\nconst { created, failed } = await resizer.generate({\n  media: fileDoc,\n  sizes: getEventMediaSizes(),   // your catalog — never raw client width/height\n  pipeline: 'listing',\n});\n// No original → ResizeNoOriginalError. Every variant failed → ResizeGenerateError.\n// Some fail → no throw, failed > 0. created is this call only.\n```\n\n`created` is **only what this call made**. A second `generate` with the same catalog returns\n`{ created: [], failed: 0 }` because everything already exists — treat an empty `created` as\n\"nothing new was needed\", never as failure. An SVG original is the same: pass-through, never\nrasterized, `{ created: [], failed: 0 }`.\n\n**Hybrid:** `generate` the above-the-fold sizes at upload and let `resolve` lazily fill the heavy\nones on demand — or `prewarm` the whole catalog at upload and let `resolve` cover anything added\nlater. A host that starts eager can graduate to lazy (or pre-warm) with no migration.\n\n---\n\n## Errors\n\nEvery error this module throws extends **`ResizeError`**, so one check separates \"the resize\nmodule rejected this\" from a `sharp` crash or an S3 timeout. The subclass answers the only\nquestion a catch block actually has — what to do about it:\n\n| Class | Means | Do |\n|---|---|---|\n| `ResizeSetupError` | wiring/bootstrap is wrong | fix your code; retrying never helps |\n| `ResizeConfigError` | host config invalid or violates an invariant | crash at boot |\n| `ResizeMediaError` | this media record is unusable | skip it; don't retry |\n| ↳ `ResizeNoOriginalError` | `generate` called with no `original` | upload the source first |\n| `ResizeGenerateError` | the operation produced nothing | inspect `failed` / `requested` |\n| `ResizeStorageError` | transient storage I/O | a retry may help |\n| `ResizeSecurityError` | a refusal (path traversal, cross-bucket) | never retry; log loudly |\n\n```ts\nimport { ResizeError, ResizeNoOriginalError } from '@adaptivestone/framework-module-resize';\n\ntry {\n  await resizer.generate({ media, sizes });\n} catch (err) {\n  if (err instanceof ResizeNoOriginalError) return badRequest('upload the image first');\n  if (ResizeError.isResizeError(err)) return badRequest(err.message);  // any module rejection\n  throw err;                                                           // not ours — let it bubble\n}\n```\n\nEach error also carries a stable, machine-readable `err.code` (`RESIZE_NO_ORIGINAL`,\n`RESIZE_STORAGE_REQUIRED`, `RESIZE_FS_PATH_TRAVERSAL`, …) for logging and alerting, plus the\nusual `err.name` and `err.cause`.\n\n**Prefer `ResizeError.isResizeError(err)` over `instanceof` across a package boundary.** If two\ncopies of this package end up in one `node_modules` tree the class identities differ and\n`instanceof` silently returns `false` — exactly when you most need the check to work.\n`isResizeError` tests a registered symbol instead of the prototype chain, so it keeps working.\n\n---\n\n## Drivers & seams\n\nFour seams, each a single active strategy fixed at construction. Two ship drivers; two default to\nframework-backed drivers when omitted, so a standard host wires only `transport` + `storage`.\nEvery driver lives behind its own package subpath (the core entry never loads driver deps).\n\n| Seam | Option | Shipped | Subpath import |\n|---|---|---|---|\n| Queue transport | `transport?` | `MongoTransport`, `SqsTransport` | `…/transports/mongo.js`, `…/transports/sqs.js` |\n| Storage | `storage` **(required)** | `LocalFsStorage`, `S3Storage` | `…/storage/fs.js`, `…/storage/s3.js` |\n| Media store | `mediaStore?` | `FrameworkMediaStore` (default) | `…/mediaStore/framework.js` |\n| Lock provider | `lockProvider?` | `FrameworkLockProvider` (default) | `…/locks/framework.js` |\n\n`storage` is the one **required** option (both modes need it). `transport` is optional (omit for\neager-only). `mediaStore`/`lockProvider` default to the framework drivers. Reach the process-wide\ninstance anywhere via `getResizer()` (throws a clear error if none was constructed).\n\n### `MongoTransport`\n\nOption-less: `new MongoTransport()`. Backed by the scaffolded `ResizeTask` model; uses the\n`config.queue` lease/retry knobs. No optional deps.\n\n### `SqsTransport({ … })`\n\n| Option | | |\n|---|---|---|\n| `queueUrl` | **required** | the SQS queue URL |\n| `region`, `endpoint` | optional | AWS region / custom endpoint |\n| `visibilityTimeout` | optional | seconds; passed to `sqs-consumer` |\n| `heartbeatInterval` | optional | seconds; extends visibility during long resizes (SQS analog of the Mongo lease heartbeat) |\n| `client` | optional | bring-your-own configured `SQSClient` (else built from `region`/`endpoint` on first use) |\n\nCredentials are never options — they resolve via the standard AWS provider chain. Dead-lettering is\n**native** (configure the queue's redrive policy with `maxReceiveCount = config.queue.maxAttempts`);\n`onTaskDeadLettered` does not fire for SQS.\n\n### `LocalFsStorage({ … })`\n\n| Option | | |\n|---|---|---|\n| `rootDir` | **required** | files land under this directory |\n| `publicBaseUrl` | **required** | URL prefix for `publicUrl()`, e.g. `/media` |\n\nDefault story for tests and first-week local. Same `download` / `upload` / `publicUrl` contract.\nOption is `publicBaseUrl` (never `publicUrl`) so it cannot shadow the method.\n\nThe host must (1) write originals under `rootDir` at `original.key`, (2) serve `rootDir` at\n`publicBaseUrl` (otherwise every URL 404s), and (3) treat this as a **local/dev** store:\n`visibility` is accepted and ignored — originals and previews share one tree.\n\n### `S3Storage({ … })`\n\n```ts\nnew S3Storage({\n  bucketPublic,\n  bucketPrivate,\n  publicBaseUrl, // alias of the old `publicUrl` for one minor\n  client,        // existing S3Client — env/keys stay in the host\n});\n```\n\n| Option | | |\n|---|---|---|\n| `bucketPublic` | **required** | previews land here (`public` visibility) |\n| `bucketPrivate` | optional | originals (`private`); defaults to `bucketPublic` |\n| `publicBaseUrl` | optional | CDN/base URL for public objects |\n| `publicUrl` | optional | **deprecated** alias of `publicBaseUrl` (one minor) |\n| `region`, `endpoint`, `forcePathStyle` | optional | S3-compatible targets (MinIO / localstack / R2) |\n| `client` | optional | bring-your-own configured `S3Client` — show this first |\n\n`publicUrl()` is **pure and I/O-free** (called on the read path). No per-object ACL — public access\nis a bucket policy. Credentials via the AWS provider chain. `download`/`publicUrl`/`signedUrl`\nenforce a **bucket allowlist**: a stored `ref.bucket` must be one of the configured\n`bucketPublic`/`bucketPrivate`, else they throw a named error — so a tampered media-doc `bucket`\ncan never become a cross-bucket read or an attacker-controlled hostname in a URL.\n\n### Custom driver = implement the interface\n\nAny seam takes a plain object (or class) that satisfies the interface — no `app` parameter; it\ncloses over its own client. For plain S3 use `S3Storage`; for anything else (GCS, filesystem, R2):\n\n```ts\nnew Resizer({ /* … */, storage: {\n  download: (ref) => s3.getObject(ref.bucket!, ref.key),\n  upload: async ({ key, body, contentType, visibility }) => {\n    const bucket = visibility === 'public' ? 'my-cdn' : 'my-originals';\n    await s3.putObject(bucket, key, body, contentType);\n    return { bucket, key };               // ← persisted onto the preview/original\n  },\n  publicUrl: (ref) => `https://cdn.example.com/${ref.key}`,   // pure; no I/O\n  signedUrl: (ref, ttl) => s3.getSignedUrl(ref.bucket!, ref.key, ttl),\n}});\n```\n\nThe same pattern swaps `mediaStore` (e.g. another DB/ORM) or `lockProvider` (e.g. Redis/redlock).\nContract types (`QueueTransport`, `ResizeStorage`, `MediaStore`, `LockProvider`, …) are exported\nfrom the main entry for custom-driver authors.\n\n---\n\n## Pipelines & hooks\n\n**Pipelines** are named per-media-type pixel work, selected per read call by name. The worker runs\nin a separate process, so the task carries only the pipeline **name** — the worker resolves the\nfunctions from its own registry (bootstrap runs in both processes).\n\nPipeline names are not part of preview identity. For the same media, dispatch locks, worker\nlocks, and stored previews are shared by size + format + filters across all pipelines. Use a\nconsistent pipeline for each media record. If the same media needs multiple renderings at the\nsame size and format, give each rendering distinct `filters` and pass those filters on reads too.\n\n```ts\npipelines: {\n  photo: {\n    beforeSteps:  [detectAndBlurPlates, detectAndBlurFaces],  // run ONCE on the source, before any resize\n    variantSteps: [(img, { variant }) => variant.filters?.blur ? img.blur(Number(variant.filters.blur)) : img],\n  },\n  avatar: {},                                                 // no special processing\n}\n// later / from another module: getResizer().registerPipeline('premium', { … })  (last-wins per name)\n```\n\n- **`beforeSteps`** — ordered, awaited, once per task on the source buffer. The home for detection\n  metadata and pixel redaction (plate/face blur) that must apply to every variant. A throwing step\n  fails the task (hard-stop on, e.g., an NSFW verdict).\n- **`variantSteps`** — ordered (registration order matters) per-variant chain, after resize, before\n  encode. The home for keyed `filters` and anything sized relative to the output.\n\n> ⚠ **Put a watermark in `variantSteps`, not `beforeSteps`.** Baked onto the original once, a\n> watermark scales down with each variant and becomes unreadable on small sizes.\n\n> **`ctx` does NOT cross the queue.** In the lazy worker `ctx === {}` — the task carries only\n> `{ mediaId, pipeline, previews }`. Durable per-media data a step needs must be read from the\n> loaded `media` doc (or persisted onto it earlier). The full caller `ctx` reaches steps **only** in\n> eager mode (`generate`, same process).\n\n**Hooks** are the cross-cutting seams. Taps run in registration order, awaited sequentially, and are\nerror-isolated (a throwing tap is logged, never breaks the read/worker flow).\n\n| Hook | Kind | Signature | Runs where |\n|---|---|---|---|\n| `resolveSizes` | waterfall | `(sizes, ctx) => sizes` | read path (real `ctx`) |\n| `beforeEnqueue` | waterfall | `(missing, ctx) => missing` | read path (real `ctx`) |\n| `formatPublicUrls` | waterfall | `(decision, ctx) => unknown` | read path (real `ctx`) |\n| `onPreviewGenerated` | observer | `(preview, ctx)` | worker (`ctx === {}`) |\n| `afterTaskComplete` | observer | `(task, ctx)` | worker (`ctx === {}`) |\n| `onTaskFailed` | observer | `(task, error, ctx)` | per failed attempt (will retry) |\n| `onTaskDeadLettered` | observer | `(task, error, ctx)` | task exhausted `maxAttempts` (host can alert/page) |\n\nRegister at construction (`hooks:`) or later via `getResizer().hook(name, fn)` (appends). Every\nobserver is **also** mirrored on the framework event bus as `resize:<name>` (e.g.\n`resize:onTaskDeadLettered`), fire-and-forget, for ecosystem subscribers — but the typed `hook()`\nregistry stays the primary contract because it is awaited and error-isolated.\n\nTaps are **typed** (`HookSignatures`): `hooks:` and `hook(name, fn)` infer each tap's exact\nsignature from its name, so autocomplete works and a wrong argument/return shape is a compile error\ninstead of a silent `any`. In every observer the `task` argument is the transport-agnostic\n`LeasedTask` (`{ taskId, mediaId, pipeline, previews }`) on **both** the Mongo and SQS transports —\nnever a raw driver document — so a host tap is portable across transports.\n\n---\n\n## Sizes & identity\n\nA size becomes a canonical **size key** via `getSizeKey`, and the full lookup/lock **identity** is\n`sizeKey:format:filterSig`. Filters are part of identity (empty → `none`), so a blurred variant is a\ndistinct object.\n\n| Size input | Size key | Meaning |\n|---|---|---|\n| `{ width: 300, height: 300 }` | `300x300` | cropped (cover) |\n| `{ width: 620 }` | `620w` | width-only (banner/strip) |\n| `{ height: 400 }` | `400h` | height-only |\n| `{ fit: true }` | `fit` | uncropped (\"contain\"), bounded by `config.maxSize` |\n| `{ width: 300, height: 300, filters: { blur: 40 } }` | `300x300` + `blur:40` in the identity | keyed alternate rendering |\n\nThe **host owns the size catalogs** per entity, injected via `resolveSizes` + per-call `sizes`.\nIllustrative catalogs (entity names are generic examples, not prescriptive):\n\n| Entity | Sizes |\n|---|---|\n| gallery / detail image | `1760x990`, `618x360` |\n| banner / strip (width-only) | `620w` |\n| avatar | `200x200`, `160x160`, `80x80`, `50x50` |\n| thumbnail set | `100x70`, `200x140`, `400x280`, `800x560` |\n| full gallery + uncropped view | `933x700`, `1866x1400`, `360x270`, `fit` |\n| preview | `150x150`, `200x200`, `400x400` |\n\n> **Security: the catalog is an allowlist.** Never pass raw client-supplied dimensions into `sizes`\n> — resolve them against a fixed per-entity catalog first, or you invite arbitrary-resize resource\n> abuse. The module owns the identity key; the host owns which sizes are permitted.\n\n```ts\nimport {\n  formatPictureUrls,\n  isCatalogCovered,\n  resizeMediaPaths,\n} from '@adaptivestone/framework-module-resize';\n\nisCatalogCovered(media, sizes, formats); // optional skip; generate is already a no-op when covered\nFile.find().select(['mediaType', ...resizeMediaPaths]);\nformatPictureUrls(decision, { id }); // unfiltered <picture> map; filtered variants stay on decision\n```\n\n---\n\n## Config reference\n\n`src/config/resize.ts` (scaffolded, editable) spreads the module defaults and is deep-merged over\nthem by `getResizeConfig()` — override any knob at any depth. **Arrays REPLACE** (so\n`formats: ['webp','avif']` doesn't concat to five); nested objects merge field-by-field.\n\n| Key | Default | Notes |\n|---|---|---|\n| `mediaModelName` | — (**required**) | your host media model name (`'File'`/`'Media'`) |\n| `formats` | `['jpeg','webp','avif']` | generated formats |\n| `webpAvifOnly` | `false` | when `true`, `requiredFormats()` drops `jpeg` (read + worker must agree) |\n| `maxSize` | `{ width: 2000, height: 1200 }` | the `fit` cap |\n| `animated` | `false` | `true` keeps GIF/WebP frames |\n| `encode.quality` | `{ jpeg: 80, webp: 82, avif: 64 }` | per-format — sharp codec defaults aren't perceptually comparable; never reuse one int |\n| `encode.effort` | `{ webp: 4, avif: 4 }` | encode-once + CDN-cached, so 5–6 is often worth it |\n| `encode.mozjpeg` | `true` | progressive + trellis quantization |\n| `encode.chromaSubsampling` | `'4:2:0'` | `'4:4:4'` keeps full chroma for text/logos/UI |\n| `encode.sharpen` | `{ cover: true, fit: false }` | mild unsharp after downscale (off for the large modal) |\n| `encode.flattenBackground` | `'#ffffff'` | alpha → jpeg flatten color |\n| `limits.inputPixels` | `268402689` | sharp decoder bomb guard |\n| `limits.sourcePixels` | `50_000_000` | rejected before decode, from metadata |\n| `limits.resultDimension` | `5000` | clamp on the cover branch |\n| `limits.animationFrames` | `64` | animation-bomb guard |\n| `queue.lockTtlMs` | `{ dispatch: 60000, worker: 60000 }` | worker ≤ `leaseMs` |\n| `queue.leaseMs` | `60000` | heartbeat renews at `leaseMs/2`; set ≥ ~2× worst-case encode |\n| `queue.retryBackoffMs` | `{ base: 5000, max: 300000 }` | delayed re-lease on fail |\n| `queue.maxAttempts` | `5` | delivery count before dead-letter (increments on every lease incl. reclaims, like SQS `maxReceiveCount`) |\n| `queue.idlePollMs` | `1000` | empty-lease sleep |\n| `queue.taskTimeoutMs` | `600000` | `handleTask` is raced against this; on timeout the task is failed and the slot freed (Mongo transport) |\n| `worker.enabled` | `false` | gate the worker process (env-driven in host) |\n| `worker.concurrency` | `4` | variants resized in parallel per task |\n| `worker.sharpConcurrency` | `1` | `sharp.concurrency()`; keep `concurrency × sharpConcurrency ≈ nCPU` |\n| `worker.sharpCache` | `false` | a worker processes distinct images; the op-cache mostly wastes memory |\n\nStorage buckets/URLs and the SQS queue URL are **not** config — they are driver options passed to\n`new S3Storage({...})` / `new SqsTransport({...})`.\n\n---\n\n## Operations\n\n**`ResizeTask` lifecycle** (Mongo transport): `pending → processing → completed | dead`. Retries are\ncapped at `queue.maxAttempts`, then the task is **dead-lettered** (`status:'dead'`) — the lease never\nreclaims a task past the cap, so no crash-loop runs forever. (SQS uses its native DLQ instead.)\n\n**Retention TTLs:** `completed` rows evict after 24h; `dead` rows are kept ~30 days for\ninspection/replay (edit the `expireAfterSeconds` in the scaffolded model to taste).\n\n**Dead-letter replay** is a host op. First look for an active row with the same\n`fileId` + `pipeline` + `requestKey`; if it exists, keep that row — it already represents\nthe same work. Otherwise reset the dead row:\n\n```ts\nconst activeFilter = {\n  fileId: row.fileId,\n  pipeline: row.pipeline,\n  requestKey: row.requestKey,\n  status: { $in: ['pending', 'processing'] },\n};\nlet active = await ResizeTask.findOne(activeFilter);\nif (!active) {\n  try {\n    await ResizeTask.updateOne(\n      { _id: row._id, status: 'dead' },\n      { $set: { status: 'pending', attempts: 0, leaseExpiresAt: null } },\n    );\n  } catch (error) {\n    // Another operator created the same active request after our first read.\n    if ((error as { code?: number }).code !== 11000) throw error;\n    active = await ResizeTask.findOne(activeFilter);\n    if (!active) throw error;\n  }\n}\n```\n\nThe active-row lookup is important because the partial unique index rejects two live copies\nof the same request. If a concurrent operator creates one after the lookup, the example\nre-reads that active row instead of retrying the dead row.\n\n**Delivery is at-least-once** (both transports); the worker is **idempotent** — re-running a task\nfor an already-generated identity skips via the existing-preview check, never duplicates.\n\n**SVG originals are pass-through** — when `original.contentType === 'image/svg+xml'` the read path\nserves a public original at every requested size/format and never resizes or enqueues. A private\noriginal is served only through a successful authorized `signedUrl`; anonymous reads return no\noriginal URL. **SVG sanitization is host-owned** (sanitize at upload before storing).\n\n**Original visibility is explicit.** Storage drivers that can prove an original is public should\nimplement `canServeOriginalPublicly(ref)`. The engine never treats an arbitrary custom driver's\n`publicUrl` as proof and never falls back from a failed private presign to a public URL. Existing\npreviews remain the preferred public read path.\n\n**Deleting media / storage cleanup is host-owned.** The module appends previews but does not delete\nthem; removing a media doc's storage objects (originals + derivatives) is your lifecycle.\n\n---\n\n## Host responsibilities\n\nThe module owns the resize core; the host owns everything domain-specific (spec §15):\n\n- The public **response DTO shape** (via `formatPublicUrls`).\n- **Which domain models** attach media and the **size catalogs** per entity (via `resolveSizes` +\n  per-call `sizes` — treat catalogs as allowlists).\n- **Data migration** from any legacy preview schema.\n- **Domain image analysis** — NSFW/object detection, plate/face blur, watermark, masking (inject via\n  pipeline `beforeSteps`/`variantSteps`).\n- **Permissions** — who may delete/replace media; the host may pass `ctx.isOwner`/`ctx.isAdmin` to\n  opt a read into a signed-original URL.\n- **SVG sanitization** and **deleting media / storage cleanup**.\n\n---\n\n## Testing\n\nThe framework enforces one app instance per process; tests install a fake via `setAppInstance(fake)`\n/ `resetAppInstance()` (the `node:test` runner isolates each file in its own process). Build fresh\nResizers with `resetResizerForTests()` between constructions. Run the full `node:test` suite with\n`npm test`.\n\n## License\n\nMIT\n","readmeFilename":"README.md"}