diff --git a/ADOPTION-PLAN.md b/ADOPTION-PLAN.md new file mode 100644 index 0000000..bc49069 --- /dev/null +++ b/ADOPTION-PLAN.md @@ -0,0 +1,237 @@ +# Adoption plan — any framework host + +`@adaptivestone/framework-module-resize@0.1.0` is a **shared** package. It must stay +comfortable in a greenfield app, a second product, and Insailing — not a private +adapter for one backend. + +Insailing was only the first production probe. Lessons below are generalized. +Insailing-specific rollout lives in that app’s `docs/resize-module-host-plan.md`, +not in this module’s API. + +--- + +## Product rule + +The module owns: size identity, sharp, persist of `previews[]`, ready/missing. + +The **host** owns: which model is media, which size catalogs exist, the public +JSON, when to fallback, delete, SVG sanitization, domain steps (blur, watermark). + +If a feature only makes sense for one app’s field names or DTO, it does **not** +belong here. + +--- + +## What every host hit (or will) + +These are product bugs, not “Insailing quirks”: + +| Problem | Why any host cares | +|---|---| +| `generate()` can return `[]` for success, missing original, SVG, or total failure | Every write path has to guess | +| Returned `previews` are **new** rows only; a second call looks like a failed generate | Every host that checks `.length` | +| Failed `formatPublicUrls` leaks `{ ready, missing }` as `output` | Easy to send garbage JSON to any frontend | +| No filesystem driver | Tests and local docker always reinvent 40 lines | +| S3 option named `publicUrl` shadows `publicUrl(ref)` | Anyone who copies the README into a class | +| `enqueueMissing` defaults **true** | Eager-only apps enqueue-or-log on every read | +| README / scaffold lead with Mongo queue + worker | First install looks harder than it is | +| Main entry loads `ResizeTask` | Fine on framework 5.3; noisy for eager-only / older peers | + +Not the module’s job (stay on the host): + +- Mapping `originalMetadata` → `original` (legacy schema). +- A named `generateAvatarPreviews` / Event helper. +- `resolveMany` (listings: `find().select(…).lean()` + a loop). +- Per-entity size lists (avatar vs boat vs “product card”). + +--- + +## Keep + +- One `Resizer` per process. +- `generate` / `resolve` / `prewarm`. +- Size keys (`720x720`, `620w`, `400h`, `fit`) and `previews[]`. +- Swappable storage / transport / mediaStore. +- Pipelines + hooks. +- Worker **optional**. Eager is a first-class, complete mode. + +--- + +## Change (small, host-agnostic) + +### 1. Honest `generate` + +```ts +const { created, failed } = await resizer.generate({ + media, + sizes, // host allowlist — never raw client width/height +}); +``` + +| Case | Behavior | +|---|---| +| no `media.original` | throw `ResizeNoOriginalError` | +| SVG original | `{ created: [], failed: 0 }` — success | +| every requested variant fails | throw `ResizeGenerateError` | +| some fail | no throw, `failed > 0` | +| all identities already stored | `{ created: [], failed: 0 }` | + +Name the array **`created`** (only this call). After persist, also append onto `media.previews` +so a same-request `resolve` sees the new rows. + +Export the error classes. Any host can `instanceof` without string-matching logs. + +### 2. Honest `resolve` + +- No hook / hook throws → `output === undefined`. Never pass the raw decision off as a DTO. +- No `transport` → `enqueueMissing` defaults to **`false`**. +- Optional helper `formatPictureUrls(decision, { id, mediaType })` builds a **generic** + `` map: + +```ts +{ + mediaType?: string, + id?: string, + sizes: { + [sizeKey: string]: { + [format: string]: { url: string, contentType: string } + } + } +} +``` + +`sizeKey` is whatever identity already is (`720x720`, `620w`, `fit`). +This is a convenience, **not** “the Insailing contract”. Another app uses its own hook. + +### 3. `LocalFsStorage` + +```ts +import { LocalFsStorage } from '@adaptivestone/framework-module-resize/storage/fs.js'; + +new LocalFsStorage({ + rootDir: './var/media', + publicBaseUrl: '/media', +}); +``` + +Same `download` / `upload` / `publicUrl`. Option name `publicBaseUrl` (never `publicUrl`). + +Default story for tests and first-week local: FS. S3 when the host has buckets. + +### 4. S3 — document, do not fork + +Already accepts `client`. Show that first in README: + +```ts +new S3Storage({ + bucketPublic, + bucketPrivate, + publicBaseUrl, // alias old `publicUrl` for one minor + client, // existing S3Client — env/keys stay in the host +}); +``` + +No second S3 wrapper in the package. + +### 5. Tiny shared helpers + +```ts +isCatalogCovered(media, sizes, formats): boolean +resizeMediaSelect = 'original previews' // plus host fields they already need +``` + +`resizeMediaSelect` is the **module** fields `resolve`/`generate` read. Hosts append +`mediaType`, `name`, … themselves. Do not bake Insailing’s `mediaType` into the +constant as if every app has it — document: + +```ts +.select(`${resizeMediaSelect} mediaType`) +``` + +or export only `['original', 'previews']` and let the host join. + +Prefer: + +```ts +export const resizeMediaPaths = ['original', 'previews'] as const; +``` + +Hosts: `.select(['mediaType', ...resizeMediaPaths])`. + +### 6. Docs and scaffold: eager first + +`resize-scaffold --eager` is already there. Make the README **open** with: + +```ts +import { Resizer } from '@adaptivestone/framework-module-resize'; +import { LocalFsStorage } from '@adaptivestone/framework-module-resize/storage/fs.js'; + +export const resizer = new Resizer({ + storage: new LocalFsStorage({ rootDir: './var/media', publicBaseUrl: '/media' }), +}); +// after Server.init() +await resizer.generate({ media, sizes: [{ width: 320, height: 320 }] }); +const { decision } = await resizer.resolve({ media, sizes: [{ width: 320, height: 320 }] }); +``` + +Queue, `ResizeTask`, `ResizeWorker` = a later section (“when listings are huge”). + +Construct **after** `Server.init()` (or lazily on first request). Do not construct in +`server.ts` before `startServer()`. + +### 7. Schema + +Module schema stays `original` + `previews` (fragment). +New apps spread the fragment from day one — no `toMediaLike`. +Old apps dual-write. No aliases in `FrameworkMediaStore`. + +### 8. `./eager` subpath — 0.3, not 0.2 + +Nice when a host must not load `ResizeTask`. Not required if peer is framework ≥ 5.2. + +--- + +## Do not do + +- Insailing field names, catalogs, or `getPublicUrls` shape as the only DTO. +- `app` on every method. +- Storage delete / CDN purge. +- Watermark, NSFW, plate blur in core (host `pipeline` steps). +- Change identity keys or default encode table. +- Require a worker. +- `resolveMany`. +- Map legacy `originalMetadata` / `resizedMetadata`. + +--- + +## Ship + +One PR on this branch — not five, no extra GitHub workflow. `npm test` + `types:check`, then publish **0.2.0** the usual manual way (`RELEASE.md`). + +In that PR: honest `generate` / `resolve`, `LocalFsStorage`, S3 `publicBaseUrl`, `isCatalogCovered` + `resizeMediaPaths`, README eager+FS first, scaffold `--eager` uses FS (not a Mongo transport TODO). + +--- + +## Done when (any new app) + +A greenfield host, after scaffold `--eager` and one size list of their own: + +- uploads an original onto `media.original`; +- calls `generate` and can tell success from failure without a wrapper; +- calls `resolve` and either maps `decision` or uses `formatPictureUrls`; +- runs locally and in CI with `LocalFsStorage` and no AWS; +- can add a second entity by passing another `sizes` array only. + +If the next product still needs a 150-line wrapper, 0.2 failed. + +--- + +## Appendix — Insailing (evidence only) + +First probe: profile avatar, eager, dual schema, custom FS + S3 wrapper. + +Next **in that app** (not module work): Auth → `resizeImageByHandle` (four catalogs, +including `620w`) → listing `.select` must include `original`/`previews`. +Sitemap `uploadRaw` and video are out of scope. + +Do not let those catalogs or `.select('resizedMetadata')` leak into the package API. diff --git a/AGENTS.md b/AGENTS.md index 7c959b1..5f60685 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,12 +10,11 @@ version-matched to the installed package — prefer it over training-data memory Humans: see `README.md` (same folder). Full docs: https://framework.adaptivestone.com/docs/resize API ground truth: the installed `dist/index.d.ts` (main entry) and `dist/types.d.ts`. -**What it is:** lazy image resizing for `@adaptivestone/framework`. Uploads store only the -original. The read path decides per size + format + filters whether a preview is ready or -missing: ready → URL now; missing → enqueued and generated by a separate sharp worker, then -appended to the host media doc's `previews[]`. Three modes share one core and one stored shape: -lazy (default, on read), pre-warm (`prewarm()` queues at upload), eager (`generate()` inline, -no queue/worker needed). +**What it is:** image resizing for `@adaptivestone/framework`. Uploads store only the +original. Three modes share one core and one stored shape: eager (`generate()` inline, +no queue/worker — start here), lazy (on read, worker fills `previews[]`), pre-warm +(`prewarm()` queues the catalog at upload). The read path decides per size + format + +filters whether a preview is ready or missing. ## Integrate (in order) @@ -32,12 +31,14 @@ no queue/worker needed). 2. Scaffold the integration files (never overwrites existing files; `--force` to regenerate): ```bash - npx resize-scaffold # or: --eager (no queue/worker), --eject (full editable model) + npx resize-scaffold --eager # start here (no queue/worker) + # npx resize-scaffold # lazy: also emits ResizeTask + ResizeWorker + # npx resize-scaffold --eject # full editable ResizeTask model ``` - Emits `src/resizer.ts` (construction site), `src/models/ResizeTask.ts` (thin shim), - `src/commands/ResizeWorker.ts` (re-export), `src/config/resize.ts` (editable config), and - appends a pointer to this guide into the host's `AGENTS.md` + `--eager` emits `src/resizer.ts` (LocalFsStorage already wired) + `src/config/resize.ts`. + Default (lazy) also emits `src/models/ResizeTask.ts` and `src/commands/ResizeWorker.ts`. + Appends a pointer to this guide into the host's `AGENTS.md` (`--agents claude|print|skip` to redirect or suppress it). 3. Wire the drivers in `src/resizer.ts` — ONE constructor literal. `storage` is REQUIRED; @@ -46,20 +47,24 @@ no queue/worker needed). ```ts 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'; + import { LocalFsStorage } from '@adaptivestone/framework-module-resize/storage/fs.js'; export const resizer = new Resizer({ - transport: new MongoTransport(), - storage: new S3Storage({ - bucketPublic: 'my-cdn', - bucketPrivate: 'my-originals', - publicUrl: 'https://cdn.example.com', + storage: new LocalFsStorage({ + rootDir: './var/media', + publicBaseUrl: '/media', }), }); ``` - Other shipped drivers: `SqsTransport` from + Construct **after** `Server.init()` (or lazily on first request). Do not construct in + `server.ts` before `startServer()`. + + Other shipped drivers: `S3Storage` from + `@adaptivestone/framework-module-resize/storage/s3.js` (options: `bucketPublic` required; + `publicBaseUrl` — alias of the old `publicUrl` for one minor; `client` first when the host + already has an `S3Client`), `MongoTransport` from + `@adaptivestone/framework-module-resize/transports/mongo.js`, `SqsTransport` from `@adaptivestone/framework-module-resize/transports/sqs.js` (options: `queueUrl` required; `region`, `endpoint`, `visibilityTimeout`, `heartbeatInterval`, `client`), `FrameworkMediaStore` from `@adaptivestone/framework-module-resize/mediaStore/framework.js`, @@ -68,8 +73,8 @@ no queue/worker needed). (`QueueTransport`, `ResizeStorage`, `MediaStore`, `LockProvider`) — no `app` parameter; a driver closes over its own client. -4. Add `import './resizer.ts';` once in `src/server.ts` — it must run in EVERY process - (API and worker). +4. Import `./resizer.ts` from the process that needs it (API; and the worker, if any) + **after** `Server.init()`. 5. Set the one required config field in the scaffolded `src/config/resize.ts`: `mediaModelName: 'File'` (your host media model's name). @@ -93,15 +98,17 @@ Read path (DTO builders / controllers). `resolve` NEVER throws and never runs sh variants are enqueued and the decision is returned immediately: ```ts -import { getResizer } from '@adaptivestone/framework-module-resize'; +import { formatPictureUrls, getResizer } from '@adaptivestone/framework-module-resize'; -const { output } = await getResizer().resolve({ +const { decision, output } = await getResizer().resolve({ media: fileDoc, pipeline: 'default', - sizes: [{ width: 620 }, { fit: true }, { width: 300, height: 300, filters: { blur: 40 } }], + sizes: [{ width: 620 }, { fit: true }, { width: 300, height: 300 }], ctx: { isOwner }, }); -// `output` is whatever your formatPublicUrls hook returns; the raw decision is also available. +// `output` is your formatPublicUrls hook (undefined if no hook / hook throws). +// formatPictureUrls skips filtered variants — map `decision` for those. +const picture = output ?? formatPictureUrls(decision, { id: String(fileDoc.id) }); ``` Upload handler, pre-warm mode (non-blocking; the worker fills the cache before the first read): @@ -113,7 +120,22 @@ const { enqueued } = await getResizer().prewarm({ media: fileDoc, sizes: catalog Upload handler, eager mode (blocking; requires a Resizer constructed WITHOUT `transport`): ```ts -const { previews } = await getResizer().generate({ media: fileDoc, sizes: catalog }); +const { created, failed } = await getResizer().generate({ + media: fileDoc, + sizes: catalog, +}); +``` + +No original throws `ResizeNoOriginalError`; every requested variant failing throws +`ResizeGenerateError`. `created` is only this call; `failed > 0` means a partial success. +`generate` also appends `created` onto `media.previews` (when persist is on) so a same-request +`resolve({ media })` sees them. + +Listing queries: + +```ts +import { resizeMediaPaths } from '@adaptivestone/framework-module-resize'; +File.find().select(['mediaType', ...resizeMediaPaths]); ``` Hooks are typed — register at construction (`hooks:`) or later via `getResizer().hook(name, fn)`. @@ -152,6 +174,7 @@ Observers (worker side): `onPreviewGenerated`, `afterTaskComplete`, `onTaskFaile | boot throws `queue.lockTtlMs.worker … must be ≤ queue.leaseMs` | raise `queue.leaseMs` or lower `queue.lockTtlMs.worker` | | previews never appear | the worker process isn't running, or `worker.enabled` is `false` in that process | | first read of a new size is slow to fill | lazy mode working as designed — call `prewarm()` at upload if it matters | +| `resolve` `output` is `undefined` | no `formatPublicUrls` hook (or it threw) — map `decision` or use `formatPictureUrls` | Config knobs: see the "Config reference" table in `README.md`; the defaults object is `defaultResizeConfig` (main entry). diff --git a/README.md b/README.md index ed6e192..20aa999 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,13 @@ # @adaptivestone/framework-module-resize -Lazy image resizing for [`@adaptivestone/framework`](https://framework.adaptivestone.com). -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`](https://sharp.pixelplumbing.com). 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. +Image resizing for [`@adaptivestone/framework`](https://framework.adaptivestone.com). +Upload only the **original**; generate resized variants with [`sharp`](https://sharp.pixelplumbing.com). +**Eager** mode (`generate` at upload) is a complete first-class path — no queue, no worker. +Lazy and pre-warm share the same core and the same `previews[]` when listings get huge. + +Everything the module touches — storage, the optional queue transport, 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. @@ -14,29 +15,29 @@ synchronous all-or-nothing cost and their three incompatible response shapes. > **Coding agents** (Claude Code, Cursor, Codex, …): read [`AGENTS.md`](./AGENTS.md) — the > machine-oriented integration guide that ships with this package. +### Migrating from 0.1 + +- `generate` returns `{ created, failed }` (not `{ previews }`). No original → + `ResizeNoOriginalError`. Every requested variant failed → `ResizeGenerateError`. +- `resolve` `output` is **`undefined`** unless you registered `formatPublicUrls` (or if that + hook threw). Map `decision`, or call `formatPictureUrls(decision, { id })`. +- No `transport` → `enqueueMissing` defaults to **`false`** (no warn-on-every-read). + --- ## 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 +upload ─▶ store the ORIGINAL ─▶ generate({ media, sizes }) ─▶ previews[] on the media doc + read ─▶ resolve({ media, sizes }) ─▶ ready URLs (or missing, if you skipped generate) ``` -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. +Eager is the default story. `generate` runs the same sharp core inline at upload and appends +onto both the store and the in-memory `media.previews`, so a same-request `resolve({ media })` +sees the new rows. -This keeps `sharp` + storage I/O off your HTTP create/update handlers. +When listings are huge, skip `generate` and add a transport + worker: `resolve` enqueues +missing variants instead. `sharp` stays off the HTTP **read** path either way. --- @@ -56,7 +57,7 @@ bootstrap, not at first I/O. |---|---| | **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) | +| **Local filesystem** (`/storage/fs.js`) / Mongo transport / framework media store / locks | nothing (no optional deps) | ### Scaffold the integration files @@ -64,7 +65,7 @@ The framework discovers models and commands by scanning your `src/` folder, so a must live in your app. Generate them once: ```bash -npx @adaptivestone/framework-module-resize resize-scaffold +npx @adaptivestone/framework-module-resize resize-scaffold --eager ``` It emits (into `process.cwd()`, or `--out `), **never overwriting** without `--force`: @@ -72,74 +73,99 @@ It emits (into `process.cwd()`, or `--out `), **never overwriting** without | 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 | +| `src/models/ResizeTask.ts` | thin shim (only without `--eager`) | +| `src/commands/ResizeWorker.ts` | worker command re-export (only without `--eager`) | 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). +no drift). `--eager` wires `LocalFsStorage`; omit the flag for a Mongo transport + a storage TODO. 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 +(write the full editable model instead of the shim, for custom fields/indexes), `--agents ` (where to write the append-only, marker-idempotent pointer to the shipped [`AGENTS.md`](./AGENTS.md); default `agents` = the host `AGENTS.md`), `--force`, `--out `. --- -## Quick start (lazy mode, Mongo + S3) +## Quick start (eager + local filesystem) + +Start here. No queue, no worker, no AWS. `npx resize-scaffold --eager` emits this wiring. + +**1. Wire the Resizer** after `Server.init()` (or lazily on first request). One Resizer per +process — a second `new Resizer()` throws. + +```ts +import { Resizer } from '@adaptivestone/framework-module-resize'; +import { LocalFsStorage } from '@adaptivestone/framework-module-resize/storage/fs.js'; + +export const resizer = new Resizer({ + storage: new LocalFsStorage({ rootDir: './var/media', publicBaseUrl: '/media' }), +}); +``` + +**2. At upload**, after the original is on `media.original`: + +```ts +const { created, failed } = await resizer.generate({ + media, + sizes: [{ width: 320, height: 320 }], +}); +const { decision } = await resizer.resolve({ + media, // generate already appended `created` onto media.previews + sizes: [{ width: 320, height: 320 }], +}); +``` + +**3. Set your media model name** in `src/config/resize.ts` (the one required field) and spread +`resizeMediaSchemaFragment` into the model so `original` + `previews[]` exist. Listing queries: + +```ts +import { resizeMediaPaths } from '@adaptivestone/framework-module-resize'; +File.find().select(['mediaType', ...resizeMediaPaths]); +``` + +S3 when you have buckets; a queue when listings are huge — both are later sections. + +--- + +## When listings are huge (lazy / queue) -**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). +Add a `transport` and run `ResizeWorker`. Missing variants are enqueued on `resolve()` (or +pushed at upload with `prewarm()`). ```ts -// src/resizer.ts — imported by src/server.ts so it runs in EVERY process (API + worker) +// src/resizer.ts — construct after Server.init(); import from API and worker processes 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 + transport: new MongoTransport(), // or new SqsTransport({ queueUrl, region }) + storage: new S3Storage({ bucketPublic: 'my-cdn', bucketPrivate: 'my-originals', - publicUrl: 'https://cdn.example.com', + publicBaseUrl: 'https://cdn.example.com', + client, // existing S3Client — env/keys stay in the host }), - // mediaStore / lockProvider omitted → framework-backed defaults pipelines: { default: {}, - listing: { beforeSteps: [blurPlates] }, // async detector, applied once to the source + listing: { beforeSteps: [blurPlates] }, 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 + formatPublicUrls: (decision, ctx) => toHostDto(decision, ctx), }, }); ``` -**2. Import it once from `src/server.ts`** so it runs in both the API and worker processes: +**Run the worker** as a separate process (gated by `worker.enabled`): -```ts -import './resizer.ts'; -``` - -**3. Set your media model name** in `src/config/resize.ts` (the one required field): - -```ts -import defaultResizeConfig from '@adaptivestone/framework-module-resize/config/resize.js'; - -export default { - ...defaultResizeConfig, - mediaModelName: 'File', // your host media model, e.g. 'File' or 'Media' -}; +```bash +npm run cli ResizeWorker ``` Your media model (`File`/`Media`) must carry `original` (incl. `width`/`height`) and `previews[]` @@ -157,14 +183,9 @@ class File extends BaseModel { 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`): - -```bash -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: +**Read** from your DTO builders. No `app` argument — the module reads the ambient app instance. +`resolve` returns the raw `decision` and the `output` of your `formatPublicUrls` hook (`undefined` +when there is no hook or the hook throws — the raw decision is never sent as a DTO): ```ts import { resizer } from '../resizer.ts'; // or: getResizer() @@ -190,16 +211,15 @@ return output; // your own shape, produced by formatPublicUrls 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) | +| | **Lazy** (queued, on read) | **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. +> **Start eager.** It is a complete mode: no worker, no queue. **Graduate to lazy or pre-warm** +> when listings are huge and you want uploads to stay fast. The stored shape is identical, so +> you can switch later 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 @@ -219,12 +239,13 @@ while the worker fills the catalog in the background. upload handler (`ctx` reaches pipeline steps here, unlike the queued worker): ```ts -const { previews } = await resizer.generate({ +const { created, failed } = await resizer.generate({ media: fileDoc, - sizes: getEventMediaSizes(), // your catalog + sizes: getEventMediaSizes(), // your catalog — never raw client width/height pipeline: 'listing', - // persist: true (default) → $push previews + backfill dims; false → returns them for you to store }); +// No original → ResizeNoOriginalError. Every variant failed → ResizeGenerateError. +// Some fail → no throw, failed > 0. created is this call only. ``` **Hybrid:** `generate` the above-the-fold sizes at upload and let `resolve` lazily fill the heavy @@ -242,7 +263,7 @@ Every driver lives behind its own package subpath (the core entry never loads dr | Seam | Option | Shipped | Subpath import | |---|---|---|---| | Queue transport | `transport?` | `MongoTransport`, `SqsTransport` | `…/transports/mongo.js`, `…/transports/sqs.js` | -| Storage | `storage` **(required)** | `S3Storage` | `…/storage/s3.js` | +| Storage | `storage` **(required)** | `LocalFsStorage`, `S3Storage` | `…/storage/fs.js`, `…/storage/s3.js` | | Media store | `mediaStore?` | `FrameworkMediaStore` (default) | `…/mediaStore/framework.js` | | Lock provider | `lockProvider?` | `FrameworkLockProvider` (default) | `…/locks/framework.js` | @@ -269,15 +290,39 @@ Credentials are never options — they resolve via the standard AWS provider cha **native** (configure the queue's redrive policy with `maxReceiveCount = config.queue.maxAttempts`); `onTaskDeadLettered` does not fire for SQS. +### `LocalFsStorage({ … })` + +| Option | | | +|---|---|---| +| `rootDir` | **required** | files land under this directory | +| `publicBaseUrl` | **required** | URL prefix for `publicUrl()`, e.g. `/media` | + +Default story for tests and first-week local. Same `download` / `upload` / `publicUrl` contract. +Option is `publicBaseUrl` (never `publicUrl`) so it cannot shadow the method. + +The host must (1) write originals under `rootDir` at `original.key`, (2) serve `rootDir` at +`publicBaseUrl` (otherwise every URL 404s), and (3) treat this as a **local/dev** store: +`visibility` is accepted and ignored — originals and previews share one tree. + ### `S3Storage({ … })` +```ts +new S3Storage({ + bucketPublic, + bucketPrivate, + publicBaseUrl, // alias of the old `publicUrl` for one minor + client, // existing S3Client — env/keys stay in the host +}); +``` + | Option | | | |---|---|---| | `bucketPublic` | **required** | previews land here (`public` visibility) | | `bucketPrivate` | optional | originals (`private`); defaults to `bucketPublic` | -| `publicUrl` | optional | CDN/base URL for public objects | +| `publicBaseUrl` | optional | CDN/base URL for public objects | +| `publicUrl` | optional | **deprecated** alias of `publicBaseUrl` (one minor) | | `region`, `endpoint`, `forcePathStyle` | optional | S3-compatible targets (MinIO / localstack / R2) | -| `client` | optional | bring-your-own configured `S3Client` | +| `client` | optional | bring-your-own configured `S3Client` — show this first | `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` @@ -396,6 +441,18 @@ Illustrative catalogs (entity names are generic examples, not prescriptive): > — 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. +```ts +import { + formatPictureUrls, + isCatalogCovered, + resizeMediaPaths, +} from '@adaptivestone/framework-module-resize'; + +isCatalogCovered(media, sizes, formats); // optional skip; generate is already a no-op when covered +File.find().select(['mediaType', ...resizeMediaPaths]); +formatPictureUrls(decision, { id }); // unfiltered map; filtered variants stay on decision +``` + --- ## Config reference diff --git a/package-lock.json b/package-lock.json index 6a54e49..8b13911 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@adaptivestone/framework-module-resize", - "version": "0.1.0", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@adaptivestone/framework-module-resize", - "version": "0.1.0", + "version": "0.2.0", "license": "MIT", "dependencies": { "deepmerge": "^4.3.1", diff --git a/package.json b/package.json index d4ea7e8..e13fd26 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@adaptivestone/framework-module-resize", - "version": "0.1.0", - "description": "Adaptive stone node js framework module: lazy image resize (sharp)", + "version": "0.2.0", + "description": "Adaptive stone node js framework module: image resize (sharp)", "main": "./dist/index.js", "type": "module", "types": "./dist/index.d.ts", @@ -17,6 +17,7 @@ "./transports/mongo.js": "./dist/transports/mongo.js", "./transports/sqs.js": "./dist/transports/sqs.js", "./storage/s3.js": "./dist/storage/s3.js", + "./storage/fs.js": "./dist/storage/fs.js", "./mediaStore/framework.js": "./dist/mediaStore/framework.js", "./locks/framework.js": "./dist/locks/framework.js" }, diff --git a/smokeTest.ts b/smokeTest.ts index ba1baab..8b9ea81 100644 --- a/smokeTest.ts +++ b/smokeTest.ts @@ -32,7 +32,7 @@ const CHECK_CORE = `import assert from 'node:assert/strict'; const PKG = '@adaptivestone/framework-module-resize'; -// (a) main entry: exactly the 17 runtime exports, and no driver class leaks into it. +// (a) main entry: exactly the expected runtime exports, and no driver class leaks into it. const mod = await import(PKG); const expected = [ 'ResizeWorker', @@ -40,12 +40,17 @@ const expected = [ 'getResizeConfig', 'requiredFormats', 'calculateResizedDimensions', + 'formatPictureUrls', 'getFilterSig', 'getImageContentType', 'getPreviewIdentity', 'getSizeKey', + 'isCatalogCovered', 'parseSizeKey', + 'resizeMediaPaths', 'resizeMediaSchemaFragment', + 'ResizeGenerateError', + 'ResizeNoOriginalError', 'ResizeTaskModel', 'getResizer', 'Resizer', @@ -67,6 +72,7 @@ for (const driver of [ 'MongoTransport', 'SqsTransport', 'S3Storage', + 'LocalFsStorage', 'FrameworkMediaStore', 'FrameworkLockProvider', ]) { @@ -98,6 +104,7 @@ for (const [sub, sdk] of optional) { // (c) always-safe subpaths import successfully. const safe = [ ['/transports/mongo.js', 'MongoTransport'], + ['/storage/fs.js', 'LocalFsStorage'], ['/mediaStore/framework.js', 'FrameworkMediaStore'], ['/locks/framework.js', 'FrameworkLockProvider'], ['/config/resize.js', 'getResizeConfig'], @@ -190,7 +197,7 @@ try { } console.log(' ok AGENTS.md ships with the package'); - // (a) main entry imports + exposes the 17 core exports, (b) optional subpaths fail loudly + // (a) main entry imports + exposes the core exports, (b) optional subpaths fail loudly // without their SDKs, (c) the always-safe subpaths import. Runs INSIDE the consumer so // module resolution is the consumer's, exercising the real published resolution. writeFileSync(join(consumer, 'checkCore.mjs'), CHECK_CORE); diff --git a/spec/02-types-and-api.md b/spec/02-types-and-api.md index bfa46d9..0e21180 100644 --- a/spec/02-types-and-api.md +++ b/spec/02-types-and-api.md @@ -224,8 +224,8 @@ class Resizer { pipeline?: string; // selects a registered pipeline; default 'default' formats?: PreviewFormat[]; // default = requiredFormats(config) ctx?: Record; // threaded to read-path hooks; reaches pipeline steps ONLY in eager mode (04 · §8). Keys read by the engine: ctx.isOwner / ctx.isAdmin gate signedUrl originals. e.g. { entity:'event', isOwner:true } - enqueueMissing?: boolean; // default true - }): Promise<{ decision: ReadDecision; output: unknown /* whatever formatPublicUrls returns */ }>; + enqueueMissing?: boolean; // default true when a transport is set, false otherwise + }): Promise<{ decision: ReadDecision; output: unknown /* formatPublicUrls; undefined if none / throws */ }>; // --- pre-warm (queue the catalog at upload, non-blocking) — see 11 · §11.1b --- async prewarm(opts: { @@ -244,7 +244,7 @@ class Resizer { formats?: PreviewFormat[]; ctx?: Record; persist?: boolean; // default true → $push previews + backfill dims - }): Promise<{ previews: Preview[] }>; + }): Promise<{ created: Preview[]; failed: number }>; } export function getResizer(): Resizer; // the active instance; THROWS a clear error if none constructed diff --git a/spec/06-read-and-enqueue.md b/spec/06-read-and-enqueue.md index 4920e02..588a34a 100644 --- a/spec/06-read-and-enqueue.md +++ b/spec/06-read-and-enqueue.md @@ -61,15 +61,15 @@ missing variants to `enqueue`. Neither may throw into the caller's read. 8. `missing = await runWaterfall('beforeEnqueue', decision.missing, ctx)`; **assign it back to `decision.missing`** so steps 9–10 and the host's `formatPublicUrls` see the same (post-hook) set that was enqueued. -9. If `enqueueMissing` (default true) and `decision.missing.length`: when the instance has - **no `transport`** (eager-only construction — [11 · Modes](./11-modes.md)), log once and - skip (missing variants simply stay placeholders); else `await enqueue( +9. If `enqueueMissing` (default **true when a transport is set, false otherwise**) and + `decision.missing.length`: when the instance has **no `transport`** (eager-only + construction — [11 · Modes](./11-modes.md)), log once and skip (only if the host + passed `enqueueMissing: true` explicitly); else `await enqueue( mediaId, pipeline, decision.missing)` (§18) inside try/catch — enqueue must never throw into the read. -10. `output = await runWaterfall('formatPublicUrls', decision, ctx)` — the host turns the - decision into its response shape and renders placeholders/signed-originals for - `decision.missing` (where `ready:false`/`isPlaceholder` is produced for the frontend). - If no tap, `output === decision`. +10. `output = await runWaterfall('formatPublicUrls', decision, ctx, 'optional')` — the host + turns the decision into its response shape. If no tap, or every tap throws, + `output === undefined`. Never pass the raw `{ ready, missing }` off as a DTO. 11. Return `{ decision, output }`. The engine never builds a response shape and never renders a placeholder; both are the @@ -79,7 +79,7 @@ host's job, driven off `decision`. > (1) each waterfall tap is guarded inside `runWaterfall` (throws logged + skipped — §9); > (2) `enqueue` is wrapped (step 9); (3) the **entire `resolve` body** runs inside a > try/catch — any unexpected internal error is logged -> and `resolve` returns the safe value `{ decision: { ready, missing: [] }, output: decision }` +> and `resolve` returns the safe value `{ decision: { ready, missing: [] }, output: undefined }` > (the `ready` entries built so far, nothing enqueued) instead of rejecting into the caller's read. > `signedUrl` (the only I/O in the read, owner/admin-gated) is also caught and falls back to the > public URL via `storage.publicUrl`. Edge case: if `getApp()` itself throws (resolve called diff --git a/spec/10-host-integration.md b/spec/10-host-integration.md index af00319..afe3b3d 100644 --- a/spec/10-host-integration.md +++ b/spec/10-host-integration.md @@ -21,7 +21,7 @@ import { S3Storage } from '@adaptivestone/framework-module-resize/storage/s3.js' export const resizer = new Resizer({ transport: new MongoTransport(), // or new SqsTransport({ queueUrl, region }); omit entirely for eager-only (11) storage: new S3Storage({ // REQUIRED — shipped driver (05 · §10.5) or any custom ResizeStorage (05 · §10.4) - bucketPublic: 'my-cdn', bucketPrivate: 'my-originals', publicUrl: 'https://cdn.example.com', + bucketPublic: 'my-cdn', bucketPrivate: 'my-originals', publicBaseUrl: 'https://cdn.example.com', }), // mediaStore / lockProvider omitted → framework defaults (05 · §10.6) pipelines: { diff --git a/spec/11-modes.md b/spec/11-modes.md index fb78340..e6b848f 100644 --- a/spec/11-modes.md +++ b/spec/11-modes.md @@ -62,7 +62,7 @@ class Resizer { formats?: PreviewFormat[]; // default requiredFormats(config) ctx?: Record; persist?: boolean; // default true → $push previews + backfill original dims on the media doc - }): Promise<{ previews: Preview[] }>; + }): Promise<{ created: Preview[]; failed: number }>; } ``` @@ -70,26 +70,27 @@ Host usage (e.g. inside a file-upload controller): ```ts // after the original is uploaded and the media doc created: -const { previews } = await resizer.generate({ +const { created, failed } = await resizer.generate({ media: fileDoc, sizes: getEventMediaSizes(), // host's catalog pipeline: 'listing', }); -// persist:true already $push'd them; the read path now returns them all as ready. +// persist:true already $push'd `created`; the read path now returns them all as ready. ``` ### Behavior (`generate`) 0. **SVG guard (2026-07-05 review fix):** an SVG original (`contentType === 'image/svg+xml'` / - `format === 'svg'`) → log + return `{ previews: [] }` immediately — SVG is NEVER rasterized - in any mode (the guard previously lived only in the queued `processTask`, letting eager - `generate` rasterize an SVG). + `format === 'svg'`) → log + return `{ created: [], failed: 0 }` — SVG is + NEVER rasterized in any mode. +0b. **No `media.original`** → throw `ResizeNoOriginalError`. 1. Resolve config + storage + the named pipeline. **Storage is required** (throws if none). 2. `sizes = await runWaterfall('resolveSizes', sizes, ctx)`; expand to `sizes × formats × filters` identities via `getPreviewIdentity` (same as the read path). In eager mode `ctx` **is** the caller's real ctx (same process) — so pipeline steps here receive it, unlike the queued worker where `ctx === {}` ([04 · Pipelines](./04-pipelines-and-hooks.md) §8). 3. **Skip identities already present** in `media.previews` (idempotent — safe to re-run; e.g. - re-upload, or adding new sizes later). + re-upload, or adding new sizes later). All already stored → + `{ created: [], failed: 0 }`. 4. Run the **same core as `processTask`** ([07 · Worker](./07-worker.md) steps 2–8): download the original once, source-pixel guard, `beforeSteps` (once), then per-variant (bounded by `config.worker.concurrency`) `.rotate()` → `cover|fit` → `variantSteps` → encode @@ -97,7 +98,9 @@ const { previews } = await resizer.generate({ 5. If `persist` → one `mediaStore.appendPreviews(...)` call (all generated previews + `original.width/height` backfill — [05 · §10.6](./05-transport-and-storage.md)); else return them for the host to store. -6. Return `{ previews }`. +6. Every requested variant failed → throw `ResizeGenerateError`. Some fail → no throw, + `failed > 0`. Return `{ created, failed }`. After persist, append `created` onto + the caller's `media.previews` so a same-request `resolve` sees them. > **Implementation note:** write the resize core ONCE (`generatePreviews(...)` in > `resizeTask.ts`). The lazy worker's `processTask` = this core **plus** lease / locks / diff --git a/src/engine.test.ts b/src/engine.test.ts index 9d9ab60..b97fd64 100644 --- a/src/engine.test.ts +++ b/src/engine.test.ts @@ -122,6 +122,7 @@ describe('resolve — partitioning', () => { format: 'jpeg', url: 'https://cdn/p1', preview: media.previews?.[0], + contentType: 'image/jpeg', }); assert.equal(decision.missing.length, 1); assert.deepEqual(decision.missing[0], { @@ -228,7 +229,7 @@ describe('resolve — waterfall hooks', () => { assert.deepEqual(decision, { ready: [], missing: [] }); }); - test('with no formatPublicUrls tap, output === decision', async () => { + test('with no formatPublicUrls tap, output === undefined', async () => { installFakeApp(); const r = new Resizer({ storage: makeStorage() }); const { decision, output } = await r.resolve({ @@ -237,7 +238,29 @@ describe('resolve — waterfall hooks', () => { formats: ['jpeg'], enqueueMissing: false, }); - assert.equal(output, decision); + assert.deepEqual(decision, { ready: [], missing: [] }); + assert.equal(output, undefined); + }); + + test('a throwing formatPublicUrls tap yields output === undefined (does not leak the decision)', async () => { + const { errors } = installFakeApp(); + const r = new Resizer({ + storage: makeStorage(), + hooks: { + formatPublicUrls: () => { + throw new Error('dto boom'); + }, + }, + }); + const { decision, output } = await r.resolve({ + media: { id: 'm1' }, + sizes: [], + formats: ['jpeg'], + enqueueMissing: false, + }); + assert.deepEqual(decision, { ready: [], missing: [] }); + assert.equal(output, undefined); + assert.ok(errors.length >= 1); }); test('a throwing beforeEnqueue tap is skipped (missing kept intact)', async () => { @@ -372,7 +395,7 @@ describe('resolve — enqueue wiring', () => { // --------------------------------------------------------------------------- describe('resolve — no transport (eager-only host)', () => { - test('keeps missing intact, warns exactly once, never touches a transport', async () => { + test('defaults enqueueMissing to false: missing intact, no warn', async () => { const { warn } = installFakeApp(); const r = new Resizer({ storage: makeStorage() }); const { decision } = await r.resolve({ @@ -384,6 +407,19 @@ describe('resolve — no transport (eager-only host)', () => { formats: ['jpeg'], }); assert.equal(decision.missing.length, 2); + assert.equal(warn.length, 0); + }); + + test('explicit enqueueMissing:true with no transport still warns once', async () => { + const { warn } = installFakeApp(); + const r = new Resizer({ storage: makeStorage() }); + const { decision } = await r.resolve({ + media: { id: 'm1' }, + sizes: [{ width: 300, height: 300 }], + formats: ['jpeg'], + enqueueMissing: true, + }); + assert.equal(decision.missing.length, 1); assert.equal(warn.length, 1); }); }); @@ -509,6 +545,7 @@ describe('resolve — original-fits fast-path', () => { format: 'jpeg', url: 'https://cdn/orig.jpg', isOriginal: true, + contentType: 'image/jpeg', }); }); @@ -672,7 +709,7 @@ describe('resolve — never throws', () => { assert.equal(decision.ready.length, 1); assert.equal(decision.ready[0].url, 'https://cdn/p1'); assert.equal(decision.missing.length, 0); - assert.equal(output, decision); + assert.equal(output, undefined); assert.ok(errors.length >= 1); }); @@ -685,7 +722,7 @@ describe('resolve — never throws', () => { formats: ['jpeg'], }); assert.deepEqual(decision, { ready: [], missing: [] }); - assert.equal(output, decision); + assert.equal(output, undefined); assert.ok(errors.length >= 1); }); }); diff --git a/src/engine.ts b/src/engine.ts index 2dec02b..afd7801 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -34,7 +34,7 @@ export interface ResolveOpts { pipeline?: string; // selects a registered pipeline; default 'default' formats?: PreviewFormat[]; // default = requiredFormats(config) ctx?: Record; // threaded to read-path hooks; ctx.isOwner/isAdmin gate signedUrl - enqueueMissing?: boolean; // default true + enqueueMissing?: boolean; // default true when a transport is set, false otherwise } export interface PrewarmOpts { @@ -53,7 +53,7 @@ const SIGNED_ORIGINAL_TTL_SECONDS = 300; // 5 minutes /** * §17 steps 1–11. See the module header for the shape. The ENTIRE body runs inside a * try/catch (the never-throw guarantee, layer 3): on any unexpected internal error it logs - * and returns the safe value `{ decision: { ready-so-far, missing: [] }, output: }` + * and returns the safe value `{ decision: { ready-so-far, missing: [] }, output: undefined }` * instead of rejecting into the caller's read. */ export async function resolveImpl( @@ -113,6 +113,9 @@ export async function resolveImpl( } for (const format of formats) { const entry: ReadyEntry = { sizeKey, format, url, isOriginal: true }; + if (original.contentType) { + entry.contentType = original.contentType; + } if (size.filters) { entry.filters = size.filters; } @@ -139,6 +142,7 @@ export async function resolveImpl( format, url: storage.publicUrl(existing), preview: existing, + contentType: existing.contentType, }; if (size.filters) { entry.filters = size.filters; @@ -159,12 +163,16 @@ export async function resolveImpl( original.width <= size.width && // (d) not larger than the box original.height <= size.height ) { - ready.push({ + const fits: ReadyEntry = { sizeKey, format, url: await originalUrl(resizer, original, ctx), isOriginal: true, - }); + }; + if (original.contentType) { + fits.contentType = original.contentType; + } + ready.push(fits); continue; } @@ -199,9 +207,10 @@ export async function resolveImpl( ctx, )) as MissingPreview[]; - // 9. Enqueue the missing variants (default on). No transport → log ONCE and skip - // (eager-only host — missing variants stay placeholders); else enqueue, guarded. - if (opts.enqueueMissing !== false && decision.missing.length > 0) { + // 9. Enqueue the missing variants. Default follows construction: a transport means + // lazy mode (enqueue), no transport means eager-only (do not log-on-every-read). + const enqueueMissing = opts.enqueueMissing ?? resizer.transport != null; + if (enqueueMissing && decision.missing.length > 0) { if (!resizer.transport) { getApp().logger.warn( 'resize resolve: missing previews but no transport is registered — they stay placeholders (eager-only host? construct the Resizer with a transport for lazy mode)', @@ -219,11 +228,13 @@ export async function resolveImpl( } } - // 10. Host turns the decision into its response shape. No tap → output === decision. + // 10. Host turns the decision into its response shape. No tap / tap throws → + // `output === undefined` (never leak `{ ready, missing }` as a DTO). const output = await resizer.runWaterfall( 'formatPublicUrls', decision, ctx, + 'optional', ); // 11. @@ -232,7 +243,7 @@ export async function resolveImpl( // Never-throw guarantee (layer 3): the read must not break on an internal error. logResolveError(err); const safe: ReadDecision = { ready, missing: [] }; - return { decision: safe, output: safe }; + return { decision: safe, output: undefined }; } } diff --git a/src/errors.test.ts b/src/errors.test.ts new file mode 100644 index 0000000..1acff36 --- /dev/null +++ b/src/errors.test.ts @@ -0,0 +1,32 @@ +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; +import { ResizeGenerateError, ResizeNoOriginalError } from './errors.ts'; + +describe('ResizeNoOriginalError', () => { + test('is instanceof Error with a stable name and the media id', () => { + const err = new ResizeNoOriginalError('m1'); + assert.ok(err instanceof Error); + assert.ok(err instanceof ResizeNoOriginalError); + assert.equal(err.name, 'ResizeNoOriginalError'); + assert.equal(err.mediaId, 'm1'); + assert.match(err.message, /m1/); + assert.match(err.message, /no original/); + }); +}); + +describe('ResizeGenerateError', () => { + test('is instanceof Error and carries failed/requested counts', () => { + const err = new ResizeGenerateError({ + mediaId: 'm2', + failed: 3, + requested: 3, + }); + assert.ok(err instanceof ResizeGenerateError); + assert.equal(err.name, 'ResizeGenerateError'); + assert.equal(err.mediaId, 'm2'); + assert.equal(err.failed, 3); + assert.equal(err.requested, 3); + assert.match(err.message, /0 previews/); + assert.match(err.message, /3/); + }); +}); diff --git a/src/errors.ts b/src/errors.ts new file mode 100644 index 0000000..1b18e79 --- /dev/null +++ b/src/errors.ts @@ -0,0 +1,31 @@ +// Hosts `instanceof` these instead of guessing from an empty `created` array. + +/** `generate` was called on a media document with no `original`. */ +export class ResizeNoOriginalError extends Error { + readonly mediaId: string; + + constructor(mediaId: string) { + super( + `resize generate: media ${mediaId} has no original — upload the source before generate()`, + ); + this.name = 'ResizeNoOriginalError'; + this.mediaId = mediaId; + } +} + +/** `generate` requested variants and every one failed (nothing created). */ +export class ResizeGenerateError extends Error { + readonly mediaId: string; + readonly failed: number; + readonly requested: number; + + constructor(opts: { mediaId: string; failed: number; requested: number }) { + super( + `resize generate: media ${opts.mediaId} produced 0 previews with ${opts.failed} variant error(s) of ${opts.requested} requested`, + ); + this.name = 'ResizeGenerateError'; + this.mediaId = opts.mediaId; + this.failed = opts.failed; + this.requested = opts.requested; + } +} diff --git a/src/formatPictureUrls.test.ts b/src/formatPictureUrls.test.ts new file mode 100644 index 0000000..d67176f --- /dev/null +++ b/src/formatPictureUrls.test.ts @@ -0,0 +1,126 @@ +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; +import { formatPictureUrls } from './formatPictureUrls.ts'; +import type { ReadDecision } from './types.d.ts'; + +describe('formatPictureUrls', () => { + test('groups ready entries by sizeKey then format', () => { + const decision: ReadDecision = { + ready: [ + { + sizeKey: '320x320', + format: 'jpeg', + url: 'https://cdn/a.jpg', + preview: { + key: 'a.jpg', + sizeKey: '320x320', + format: 'jpeg', + contentType: 'image/jpeg', + }, + }, + { + sizeKey: '320x320', + format: 'webp', + url: 'https://cdn/a.webp', + preview: { + key: 'a.webp', + sizeKey: '320x320', + format: 'webp', + contentType: 'image/webp', + }, + }, + { + sizeKey: 'fit', + format: 'jpeg', + url: 'https://cdn/b.jpg', + preview: { + key: 'b.jpg', + sizeKey: 'fit', + format: 'jpeg', + contentType: 'image/jpeg', + }, + }, + ], + missing: [{ sizeKey: '620w', format: 'jpeg' }], + }; + const out = formatPictureUrls(decision, { id: 'm1', mediaType: 'image' }); + assert.equal(out.id, 'm1'); + assert.equal(out.mediaType, 'image'); + assert.deepEqual(out.sizes, { + '320x320': { + jpeg: { url: 'https://cdn/a.jpg', contentType: 'image/jpeg' }, + webp: { url: 'https://cdn/a.webp', contentType: 'image/webp' }, + }, + fit: { + jpeg: { url: 'https://cdn/b.jpg', contentType: 'image/jpeg' }, + }, + }); + // missing variants are not in the map (no URL yet) + assert.equal('620w' in out.sizes, false); + }); + + test('original-backed entries use contentType when known, never invent image/', () => { + const decision: ReadDecision = { + ready: [ + { + sizeKey: '300x300', + format: 'webp', + url: 'https://cdn/orig.svg', + isOriginal: true, + contentType: 'image/svg+xml', + }, + ], + missing: [], + }; + const out = formatPictureUrls(decision); + assert.equal(out.id, undefined); + assert.deepEqual(out.sizes['300x300'].webp, { + url: 'https://cdn/orig.svg', + contentType: 'image/svg+xml', + }); + }); + + test('omits contentType when unknown rather than guessing', () => { + const decision: ReadDecision = { + ready: [ + { + sizeKey: '300x300', + format: 'webp', + url: 'https://cdn/orig.jpg', + isOriginal: true, + }, + ], + missing: [], + }; + const out = formatPictureUrls(decision); + assert.deepEqual(out.sizes['300x300'].webp, { + url: 'https://cdn/orig.jpg', + }); + }); + + test('skips filtered variants so they cannot collide on sizeKey+format', () => { + const decision: ReadDecision = { + ready: [ + { + sizeKey: '300x300', + format: 'jpeg', + url: 'https://cdn/plain.jpg', + contentType: 'image/jpeg', + }, + { + sizeKey: '300x300', + format: 'jpeg', + filters: { blur: 40 }, + url: 'https://cdn/blur.jpg', + contentType: 'image/jpeg', + }, + ], + missing: [], + }; + const out = formatPictureUrls(decision); + assert.deepEqual(out.sizes['300x300'].jpeg, { + url: 'https://cdn/plain.jpg', + contentType: 'image/jpeg', + }); + }); +}); diff --git a/src/formatPictureUrls.ts b/src/formatPictureUrls.ts new file mode 100644 index 0000000..ba757e1 --- /dev/null +++ b/src/formatPictureUrls.ts @@ -0,0 +1,35 @@ +// Generic `` map — a convenience, not "the" host DTO. Filtered variants +// are omitted (they would collide on sizeKey+format); map `decision` for those. +import { getFilterSig } from './images.ts'; +import type { PictureUrls, ReadDecision } from './types.d.ts'; + +export function formatPictureUrls( + decision: ReadDecision, + opts: { id?: string; mediaType?: string } = {}, +): PictureUrls { + const sizes: PictureUrls['sizes'] = {}; + for (const entry of decision.ready) { + if (getFilterSig(entry.filters) !== 'none') { + continue; + } + let byFormat = sizes[entry.sizeKey]; + if (!byFormat) { + byFormat = {}; + sizes[entry.sizeKey] = byFormat; + } + const contentType = entry.contentType ?? entry.preview?.contentType; + const cell: { url: string; contentType?: string } = { url: entry.url }; + if (contentType) { + cell.contentType = contentType; + } + byFormat[entry.format] = cell; + } + const out: PictureUrls = { sizes }; + if (opts.mediaType !== undefined) { + out.mediaType = opts.mediaType; + } + if (opts.id !== undefined) { + out.id = opts.id; + } + return out; +} diff --git a/src/images.test.ts b/src/images.test.ts index 1462abc..7a1bda2 100644 --- a/src/images.test.ts +++ b/src/images.test.ts @@ -6,6 +6,7 @@ import { getImageContentType, getPreviewIdentity, getSizeKey, + isCatalogCovered, parseSizeKey, } from './images.ts'; @@ -252,3 +253,56 @@ describe('calculateResizedDimensions', () => { assert.equal(r.height, 1200); }); }); + +describe('isCatalogCovered', () => { + const sizes = [{ width: 20, height: 20 }]; + const formats = ['jpeg'] as const; + + test('false when no matching preview is stored', () => { + assert.equal( + isCatalogCovered({ original: { key: 'o' }, previews: [] }, sizes, [ + ...formats, + ]), + false, + ); + }); + + test('true when every size×format identity is already stored', () => { + assert.equal( + isCatalogCovered( + { + original: { key: 'o' }, + previews: [ + { + key: 'p', + sizeKey: '20x20', + format: 'jpeg', + contentType: 'image/jpeg', + }, + ], + }, + sizes, + [...formats], + ), + true, + ); + }); + + test('SVG original is covered without previews (pass-through)', () => { + assert.equal( + isCatalogCovered( + { original: { key: 'x.svg', contentType: 'image/svg+xml' } }, + sizes, + [...formats], + ), + true, + ); + }); + + test('empty sizes is covered (nothing to generate)', () => { + assert.equal( + isCatalogCovered({ original: { key: 'o' } }, [], ['jpeg']), + true, + ); + }); +}); diff --git a/src/images.ts b/src/images.ts index c37bccf..9a625ec 100644 --- a/src/images.ts +++ b/src/images.ts @@ -166,6 +166,26 @@ export function expandMissingPreviews( return requested; } +/** + * True when every `sizes × formats` identity is already stored on `media.previews` + * (or the original is SVG, which is pass-through and never needs a preview). Hosts + * use this to skip a no-op `generate` / `prewarm`. + */ +export function isCatalogCovered( + media: MediaLike, + sizes: SizeInput[], + formats: PreviewFormat[], +): boolean { + const original = media.original; + if ( + original && + (original.contentType === 'image/svg+xml' || original.format === 'svg') + ) { + return true; + } + return expandMissingPreviews(media, sizes, formats).length === 0; +} + /** * Content type for a raster PREVIEW format only. Never pass an original's format — * originals carry their own `original.contentType` (e.g. 'image/svg+xml'). diff --git a/src/index.test.ts b/src/index.test.ts index 8255046..6c69719 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -13,20 +13,25 @@ const SRC_DIR = dirname(fileURLToPath(import.meta.url)); // re-exports (contract interfaces, TResizeTask, types.d.ts) are erased and never appear here. const EXPECTED_VALUE_EXPORTS = [ 'Resizer', + 'ResizeGenerateError', + 'ResizeNoOriginalError', 'ResizeTaskModel', 'ResizeWorker', 'calculateResizedDimensions', 'defaultResizeConfig', + 'formatPictureUrls', 'getFilterSig', 'getImageContentType', 'getPreviewIdentity', 'getResizeConfig', 'getResizer', 'getSizeKey', + 'isCatalogCovered', 'parseSizeKey', 'processTask', 'requiredFormats', 'resetResizerForTests', + 'resizeMediaPaths', 'resizeMediaSchemaFragment', 'runResizeWorker', ]; @@ -36,6 +41,7 @@ const DRIVER_NAMES = [ 'MongoTransport', 'SqsTransport', 'S3Storage', + 'LocalFsStorage', 'FrameworkMediaStore', 'FrameworkLockProvider', ]; @@ -75,6 +81,8 @@ describe('public API surface (src/index.ts)', () => { 'getImageContentType', 'getResizeConfig', 'requiredFormats', + 'formatPictureUrls', + 'isCatalogCovered', ]) { assert.equal( typeof asRecord[name], @@ -88,6 +96,7 @@ describe('public API surface (src/index.ts)', () => { assert.equal(typeof api.defaultResizeConfig, 'object'); assert.equal(api.defaultResizeConfig.formats[0], 'jpeg'); // it's the actual default, not a stub assert.equal(typeof api.resizeMediaSchemaFragment, 'object'); + assert.deepEqual(api.resizeMediaPaths, ['original', 'previews']); }); test('a pure helper actually works through the re-export', () => { diff --git a/src/index.ts b/src/index.ts index 25ca31a..4da01dd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,11 +4,12 @@ // static imports inside; the main entry never resolves any driver's (optional-peer) // dependencies — so `import '@adaptivestone/framework-module-resize'` never loads the AWS // SDKs, and a missing optional peer fails LOUDLY at the host's own driver import line at -// bootstrap, not at the first I/O call. The five driver subpaths (house style: CLASSES +// bootstrap, not at the first I/O call. The six driver subpaths (house style: CLASSES // implementing the Abstract* contracts, constructed `new X(opts?)`): // import { MongoTransport } from '@adaptivestone/framework-module-resize/transports/mongo.js'; // import { SqsTransport } from '@adaptivestone/framework-module-resize/transports/sqs.js'; // import { S3Storage } from '@adaptivestone/framework-module-resize/storage/s3.js'; +// import { LocalFsStorage } from '@adaptivestone/framework-module-resize/storage/fs.js'; // import { FrameworkMediaStore } from '@adaptivestone/framework-module-resize/mediaStore/framework.js'; // import { FrameworkLockProvider } from '@adaptivestone/framework-module-resize/locks/framework.js'; // The contract INTERFACES for custom-driver authors re-export below (the VALUES live at the @@ -29,16 +30,22 @@ export { // --- read-path / eager option types (type-only) — hosts annotate their call sites --- export type { PrewarmOpts, ResolveOpts } from './engine.ts'; // --- pure identity + dimension helpers (03 · Identity) --- +export { ResizeGenerateError, ResizeNoOriginalError } from './errors.ts'; +export { formatPictureUrls } from './formatPictureUrls.ts'; export { calculateResizedDimensions, getFilterSig, getImageContentType, getPreviewIdentity, getSizeKey, + isCatalogCovered, parseSizeKey, } from './images.ts'; // --- optional `as const` media schema fragment the host spreads into File/Media (08 · §12) --- -export { resizeMediaSchemaFragment } from './models/mediaFragment.ts'; +export { + resizeMediaPaths, + resizeMediaSchemaFragment, +} from './models/mediaFragment.ts'; export type { TResizeTask } from './models/ResizeTask.ts'; // --- Mongo-transport model class (the host's scaffolded model `extends` it) + its doc type --- export { default as ResizeTaskModel } from './models/ResizeTask.ts'; @@ -46,6 +53,7 @@ export { default as ResizeTaskModel } from './models/ResizeTask.ts'; export type { BeforeStep, GenerateOpts, + GenerateResult, HookFn, HookName, HookSignatures, diff --git a/src/models/mediaFragment.test.ts b/src/models/mediaFragment.test.ts index a877bc7..d474544 100644 --- a/src/models/mediaFragment.test.ts +++ b/src/models/mediaFragment.test.ts @@ -3,11 +3,20 @@ import { readFileSync } from 'node:fs'; import { describe, test } from 'node:test'; import { fileURLToPath } from 'node:url'; import mongoose from 'mongoose'; -import { resizeMediaSchemaFragment } from './mediaFragment.ts'; +import { + resizeMediaPaths, + resizeMediaSchemaFragment, +} from './mediaFragment.ts'; // mongoose is a devDep here (the no-mongoose rule is for runtime module code — 01 · §16); // building a real Schema from the spread fragment is the point of the smoke test. +describe('resizeMediaPaths', () => { + test('lists the module fields resolve/generate read', () => { + assert.deepEqual(resizeMediaPaths, ['original', 'previews']); + }); +}); + describe('resizeMediaSchemaFragment — shape', () => { test('exposes `original` + `previews` keys', () => { assert.ok('original' in resizeMediaSchemaFragment); diff --git a/src/models/mediaFragment.ts b/src/models/mediaFragment.ts index 089940a..c2d73a7 100644 --- a/src/models/mediaFragment.ts +++ b/src/models/mediaFragment.ts @@ -52,3 +52,7 @@ export const resizeMediaSchemaFragment = { }, ], } as const; + +/** Module fields `resolve`/`generate` read. Hosts append their own: + * `.select(['mediaType', ...resizeMediaPaths])`. */ +export const resizeMediaPaths = ['original', 'previews'] as const; diff --git a/src/resizeTask.test.ts b/src/resizeTask.test.ts index 5d7d9c2..b3f2769 100644 --- a/src/resizeTask.test.ts +++ b/src/resizeTask.test.ts @@ -8,6 +8,7 @@ import { setAppInstance, } from '@adaptivestone/framework/helpers/appInstance.js'; import sharp from 'sharp'; +import { ResizeGenerateError, ResizeNoOriginalError } from './errors.ts'; import type { LockProvider } from './locks.ts'; import type { MediaStore } from './mediaStore.ts'; import { @@ -850,28 +851,46 @@ describe('generate (eager)', () => { const { storage, uploads } = makeStorage(redPng); const { mediaStore, appendCalls } = makeMediaStore(null); // load unused in eager mode const r = new Resizer({ storage, mediaStore }); - const { previews } = await r.generate({ + const result = await r.generate({ media: mediaDoc(), sizes: [{ width: 20, height: 20 }], formats: ['jpeg'], }); - assert.equal(previews.length, 1); + assert.equal(result.created.length, 1); + assert.equal(result.failed, 0); assert.equal(uploads.length, 1); assert.equal(appendCalls.length, 1); }); + test('appends created onto media.previews so a same-request resolve sees them', async () => { + installApp(); + const { storage } = makeStorage(redPng); + const { mediaStore } = makeMediaStore(null); + const r = new Resizer({ storage, mediaStore }); + const media = mediaDoc(); + const { created } = await r.generate({ + media, + sizes: [{ width: 20, height: 20 }], + formats: ['jpeg'], + }); + assert.equal(media.previews?.length, 1); + assert.equal(media.previews?.[0], created[0]); + }); + test('persist:false → returns previews without persisting (but still uploads)', async () => { installApp(); const { storage, uploads } = makeStorage(redPng); const { mediaStore, appendCalls } = makeMediaStore(null); const r = new Resizer({ storage, mediaStore }); - const { previews } = await r.generate({ - media: mediaDoc(), + const media = mediaDoc(); + const { created } = await r.generate({ + media, sizes: [{ width: 20, height: 20 }], formats: ['jpeg'], persist: false, }); - assert.equal(previews.length, 1); + assert.equal(created.length, 1); + assert.equal(media.previews?.length ?? 0, 0); assert.equal(uploads.length, 1); assert.equal(appendCalls.length, 0); }); @@ -887,12 +906,13 @@ describe('generate (eager)', () => { } as unknown as Preview; const { mediaStore, appendCalls } = makeMediaStore(null); const r = new Resizer({ storage, mediaStore }); - const { previews } = await r.generate({ + const result = await r.generate({ media: mediaDoc({ previews: [existing] }), sizes: [{ width: 20, height: 20 }], formats: ['jpeg'], }); - assert.equal(previews.length, 0); + assert.deepEqual(result.created, []); + assert.equal(result.failed, 0); assert.equal(uploads.length, 0); assert.equal(appendCalls.length, 0); }); @@ -913,24 +933,103 @@ describe('generate (eager)', () => { ); }); - test('SVG original → log + { previews: [] }, nothing uploaded or persisted (never rasterized)', async () => { + test('SVG original → log + { created: [], failed: 0 }, nothing uploaded or persisted', async () => { const { logs } = installApp(); const { storage, uploads } = makeStorage(redPng); const { mediaStore, appendCalls } = makeMediaStore(null); const r = new Resizer({ storage, mediaStore }); - const { previews } = await r.generate({ + const result = await r.generate({ media: mediaDoc({ original: { key: 'uploads/x.svg', contentType: 'image/svg+xml' }, }), sizes: [{ width: 20, height: 20 }], formats: ['jpeg'], }); - assert.deepEqual(previews, []); + assert.deepEqual(result.created, []); + assert.equal(result.failed, 0); assert.equal(uploads.length, 0); assert.equal(appendCalls.length, 0); assert.ok(logs.info.some((l) => String(l[0]).includes('SVG'))); }); + test('no original → ResizeNoOriginalError', async () => { + installApp(); + const { storage } = makeStorage(redPng); + const { mediaStore } = makeMediaStore(null); + const r = new Resizer({ storage, mediaStore }); + await assert.rejects( + () => + r.generate({ + media: { id: 'm1' }, + sizes: [{ width: 20, height: 20 }], + formats: ['jpeg'], + }), + (err: unknown) => { + assert.ok(err instanceof ResizeNoOriginalError); + assert.equal(err.mediaId, 'm1'); + return true; + }, + ); + }); + + test('every requested variant fails → ResizeGenerateError', async () => { + installApp(); + const { mediaStore } = makeMediaStore(null); + const storage: ResizeStorage = { + download: async () => redPng, + upload: async () => { + throw new Error('upload down'); + }, + publicUrl: () => '', + }; + const r = new Resizer({ storage, mediaStore }); + await assert.rejects( + () => + r.generate({ + media: mediaDoc(), + sizes: [{ width: 20, height: 20 }], + formats: ['jpeg'], + }), + (err: unknown) => { + assert.ok(err instanceof ResizeGenerateError); + assert.equal(err.failed, 1); + assert.equal(err.requested, 1); + return true; + }, + ); + }); + + test('partial failure: no throw, failed > 0, created has the successes', async () => { + installApp(); + const { storage } = makeStorage(redPng); + const { mediaStore, appendCalls } = makeMediaStore(null); + const r = new Resizer({ + storage, + mediaStore, + pipelines: { + default: { + variantSteps: [ + async (img, { variant }) => { + if (variant.format === 'webp') { + throw new Error('webp boom'); + } + return img; + }, + ], + }, + }, + }); + const result = await r.generate({ + media: mediaDoc(), + sizes: [{ width: 20, height: 20 }], + formats: ['jpeg', 'webp'], + }); + assert.equal(result.created.length, 1); + assert.equal(result.created[0].format, 'jpeg'); + assert.equal(result.failed, 1); + assert.equal(appendCalls.length, 1); + }); + test('real ctx reaches beforeSteps and variantSteps', async () => { installApp(); const { storage } = makeStorage(redPng); diff --git a/src/resizeTask.ts b/src/resizeTask.ts index 3c08b9a..2f1715b 100644 --- a/src/resizeTask.ts +++ b/src/resizeTask.ts @@ -10,6 +10,7 @@ import sharp from 'sharp'; import { getApp } from './app.ts'; import { getResizeConfig, requiredFormats } from './config/resize.ts'; +import { ResizeGenerateError, ResizeNoOriginalError } from './errors.ts'; import { runBounded } from './helpers/concurrency.ts'; import { randomHex } from './helpers/random.ts'; import { @@ -20,6 +21,7 @@ import { } from './images.ts'; import { type GenerateOpts, + type GenerateResult, getResizer, type LeasedTask, type Resizer, @@ -445,7 +447,7 @@ export async function processTask( export async function generateImpl( resizer: Resizer, opts: GenerateOpts, -): Promise<{ previews: Preview[] }> { +): Promise { const config = getResizeConfig(); const ctx = opts.ctx ?? {}; const { media } = opts; @@ -454,18 +456,9 @@ export async function generateImpl( // (04 · papercut) rather than silently keying on the literal 'undefined'. const mediaId = requireMediaId(media); - // 0. SVG guard (11 · §11.1 step 0): SVG originals are pass-through — NEVER rasterized in ANY - // mode. Log + return empty so eager `generate` matches the read path + queued worker (the - // guard previously lived only in processTask, letting eager generate rasterize an SVG). const original = media.original; - if ( - original && - (original.contentType === 'image/svg+xml' || original.format === 'svg') - ) { - getApp().logger.info( - `resize generate: media ${mediaId} original is SVG — pass-through, nothing to generate`, - ); - return { previews: [] }; + if (!original) { + throw new ResizeNoOriginalError(mediaId); } // Host size magic (real ctx in eager mode), then the active format list. @@ -476,18 +469,41 @@ export async function generateImpl( )) as SizeInput[]; const formats = opts.formats ?? requiredFormats(config); - // Expand sizes × formats into MissingPreview variants; skip unbuildable sizes + existing - // identities (idempotent re-run) and dedup — the shared expansion (also used by prewarm). + // SVG originals are pass-through — never rasterized in any mode. + if (original.contentType === 'image/svg+xml' || original.format === 'svg') { + getApp().logger.info( + `resize generate: media ${mediaId} original is SVG — pass-through, nothing to generate`, + ); + return { created: [], failed: 0 }; + } + + // Expand sizes × formats; skip unbuildable sizes + existing identities (idempotent). const requested = expandMissingPreviews(media, sizes, formats); - const { generated } = await generatePreviews(resizer, { + const persist = opts.persist !== false; + const { generated, failedCount } = await generatePreviews(resizer, { media, mediaId, requested, pipeline, ctx, useLocks: false, - persist: opts.persist !== false, + persist, }); - return { previews: generated }; + + if (requested.length > 0 && generated.length === 0 && failedCount > 0) { + throw new ResizeGenerateError({ + mediaId, + failed: failedCount, + requested: requested.length, + }); + } + + // Persist is a $push; also append onto the caller's in-memory doc so a same-request + // resolve() sees the new rows without a reload. + if (persist && generated.length > 0) { + media.previews = [...(media.previews ?? []), ...generated]; + } + + return { created: generated, failed: failedCount }; } diff --git a/src/resizer.test.ts b/src/resizer.test.ts index ea70048..614f2b2 100644 --- a/src/resizer.test.ts +++ b/src/resizer.test.ts @@ -380,10 +380,10 @@ describe('resolve/generate stubs', () => { formats: ['jpeg'], }); assert.deepEqual(decision, { ready: [], missing: [] }); - assert.equal(output, decision); + assert.equal(output, undefined); }); - test('generate is wired (no longer a stub): empty sizes → empty previews', async () => { + test('generate is wired (no longer a stub): empty sizes → empty created', async () => { // A config WITH mediaModelName so getResizeConfig() inside generateImpl does not throw. setAppInstance({ getConfig: () => ({ mediaModelName: 'File' }), @@ -391,7 +391,11 @@ describe('resolve/generate stubs', () => { logger: { info() {}, warn() {}, error() {} }, } as never); const r = new Resizer(baseOpts()); - const { previews } = await r.generate({ media: { id: 'm1' }, sizes: [] }); - assert.deepEqual(previews, []); + const result = await r.generate({ + media: { id: 'm1', original: { key: 'o', contentType: 'image/jpeg' } }, + sizes: [], + }); + assert.deepEqual(result.created, []); + assert.equal(result.failed, 0); }); }); diff --git a/src/resizer.ts b/src/resizer.ts index ef8b7b1..896ce10 100644 --- a/src/resizer.ts +++ b/src/resizer.ts @@ -143,6 +143,13 @@ export interface GenerateOpts { persist?: boolean; // default true → $push previews + backfill dims } +// `created` is only the rows THIS call produced. Empty + `failed === 0` is success +// (already stored, SVG pass-through, or an empty catalog). Total failure throws. +export interface GenerateResult { + created: Preview[]; + failed: number; +} + // Unknown pipeline name → the shared, frozen empty pipeline (no steps). One frozen // constant avoids per-call allocation + accidental mutation of a "default" (04 · §8). const EMPTY_PIPELINE: Pipeline = Object.freeze({}); @@ -232,15 +239,27 @@ export class Resizer { name: WaterfallName, value: unknown, ctx: Record, + // `optional` (0.2 formatPublicUrls): no taps / every tap throws → `undefined` + // instead of leaking the raw decision as a DTO. Other waterfalls stay `identity`. + mode: 'identity' | 'optional' = 'identity', ): Promise { + const taps = this.hooks.get(name) ?? []; + if (mode === 'optional' && taps.length === 0) { + return undefined; + } const app = getApp(); - for (const fn of this.hooks.get(name) ?? []) { + let succeeded = false; + for (const fn of taps) { try { value = await fn(value, ctx); + succeeded = true; } catch (e) { app.logger.error(`resize waterfall ${name} tap failed (skipped)`, e); } } + if (mode === 'optional' && !succeeded) { + return undefined; + } return value; } @@ -277,7 +296,7 @@ export class Resizer { pipeline?: string; // selects a registered pipeline; default 'default' formats?: PreviewFormat[]; // default = requiredFormats(config) ctx?: Record; // threaded to read-path hooks (04 · §8) - enqueueMissing?: boolean; // default true + enqueueMissing?: boolean; // default true when a transport is set, false otherwise }): Promise<{ decision: ReadDecision; output: unknown }> { return resolveImpl(this, opts); } @@ -310,7 +329,7 @@ export class Resizer { * (bounded by config.worker.concurrency, NO locks). `persist !== false` → one * mediaStore.appendPreviews (+ display-dim backfill); else the previews are returned unstored. */ - async generate(opts: GenerateOpts): Promise<{ previews: Preview[] }> { + async generate(opts: GenerateOpts): Promise { return generateImpl(this, opts); } } diff --git a/src/scaffold/command.test.ts b/src/scaffold/command.test.ts index b3940c4..0620559 100644 --- a/src/scaffold/command.test.ts +++ b/src/scaffold/command.test.ts @@ -135,7 +135,7 @@ describe('runScaffold — --eject', () => { }); describe('runScaffold — --eager', () => { - test('emits only resizer.ts + config, with the transport commented out', async () => { + test('emits only resizer.ts + config, wired to LocalFsStorage (no Mongo transport)', async () => { const { code } = await run(['--eager']); assert.equal(code, 0); @@ -144,13 +144,12 @@ describe('runScaffold — --eager', () => { assert.equal(await exists(MODEL), false); assert.equal(await exists(COMMAND), false); - // The transport line is commented (eager mode never enqueues). const resizer = await read(RESIZER); - assert.doesNotMatch(resizer, /^\s*transport: new MongoTransport/m); - assert.match(resizer, /\/\/\s*transport/); - // The now-unused MongoTransport import is commented too (4.3). - assert.doesNotMatch(resizer, /^\s*import \{ MongoTransport \}/m); - assert.match(resizer, /\/\/\s*import \{ MongoTransport \}/); + assert.doesNotMatch(resizer, /MongoTransport/); + assert.doesNotMatch(resizer, /PROVIDE_YOUR_STORAGE_DRIVER/); + assert.match(resizer, /LocalFsStorage/); + assert.match(resizer, /storage\/fs\.js/); + assert.match(resizer, /publicBaseUrl/); }); }); diff --git a/src/scaffold/command.ts b/src/scaffold/command.ts index 51d60d4..c216cb1 100644 --- a/src/scaffold/command.ts +++ b/src/scaffold/command.ts @@ -48,7 +48,7 @@ ${AGENTS_END} interface FileSpec { target: string; // host-relative path template: string; // template file name (in ./templates) - transform?: (content: string) => string; // optional post-read edit (eager mode) + transform?: (content: string) => string; // optional post-read edit } /** Templates resolve relative to THIS file → works in src (tests) and dist (postBuild copy). */ @@ -69,34 +69,14 @@ async function fileExists(abs: string): Promise { } } -// Eager-mode hosts (11 · Modes) generate synchronously at upload — no queue/worker. So the emitted -// resizer.ts must NOT wire a transport: comment out the `transport:` line in place. -function commentOutTransport(content: string): string { - return content - .split('\n') - .map((line) => { - const trimmed = line.trimStart(); - const indent = line.slice(0, line.length - trimmed.length); - if (trimmed.startsWith('transport:')) { - return `${indent}// eager mode (11 · Modes): no transport — generate() runs synchronously at upload\n${indent}// ${trimmed}`; - } - // The MongoTransport import is now unused (its only use, the transport line, is commented) — - // comment it too so a host tsc with noUnusedLocals stays clean. - if (trimmed.startsWith('import { MongoTransport }')) { - return `${indent}// eager mode (11 · Modes): MongoTransport unused (no transport)\n${indent}// ${trimmed}`; - } - return line; - }) - .join('\n'); -} - /** The files a run emits, given the flags. Eager mode drops the model + command shims. */ function planFiles(opts: { eject: boolean; eager: boolean }): FileSpec[] { - const resizer: FileSpec = { - target: RESIZER, - template: 'resizer.ts.tpl', - transform: opts.eager ? commentOutTransport : undefined, - }; + const resizer: FileSpec = opts.eager + ? { target: RESIZER, template: 'resizer.eager.ts.tpl' } + : { + target: RESIZER, + template: 'resizer.ts.tpl', + }; const config: FileSpec = { target: CONFIG, template: 'resize.config.ts.tpl' }; if (opts.eager) { return [resizer, config]; @@ -225,7 +205,7 @@ Emits (into process.cwd(), or --out ): Options: --check verify the shims exist + reference the module; exit 1 on missing/drift (no writes) --eject write the FULL editable model instead of the shim (custom fields/indexes) - --eager eager-mode hosts: emit only src/resizer.ts (no transport) + src/config/resize.ts + --eager eager-mode hosts: emit only src/resizer.ts (LocalFsStorage, no transport) + src/config/resize.ts --force overwrite existing files (default: never overwrite) --agents host pointer to the shipped AGENTS.md: agents (default: append to the host AGENTS.md, create if missing), claude (CLAUDE.md), print (stdout only), skip. @@ -297,12 +277,21 @@ export async function runScaffold( }); const code = await writeFiles(root, specs, Boolean(values.force)); await writeAgentsPointer(root, agents as AgentsMode); - console.log( - '\nDone. Next: fill the `storage` TODO in src/resizer.ts, set `mediaModelName` in', - ); - console.log( - 'src/config/resize.ts, and `import ./resizer.ts` from src/server.ts (runs in every process).', - ); + if (values.eager) { + console.log( + '\nDone. Next: set `mediaModelName` in src/config/resize.ts, construct the', + ); + console.log( + 'Resizer after Server.init() (or lazily), and call generate() at upload.', + ); + } else { + console.log( + '\nDone. Next: fill the `storage` TODO in src/resizer.ts, set `mediaModelName` in', + ); + console.log( + 'src/config/resize.ts, and `import ./resizer.ts` from src/server.ts (runs in every process).', + ); + } return code; } diff --git a/src/scaffold/templates/resizer.eager.ts.tpl b/src/scaffold/templates/resizer.eager.ts.tpl new file mode 100644 index 0000000..b1b2c37 --- /dev/null +++ b/src/scaffold/templates/resizer.eager.ts.tpl @@ -0,0 +1,23 @@ +// src/resizer.ts — the resize module's CONSTRUCTION SITE (scaffolded; edit freely). +// +// Construct AFTER Server.init() (or lazily on first request). Do not construct +// in server.ts before startServer() — the framework app must exist first. +// Import this file from the API process (eager mode has no worker): +// import './resizer.ts'; +import { Resizer } from '@adaptivestone/framework-module-resize'; +import { LocalFsStorage } from '@adaptivestone/framework-module-resize/storage/fs.js'; +// S3 / S3-compatible storage (install the optional AWS peers first — 05 · §10.5): +// import { S3Storage } from '@adaptivestone/framework-module-resize/storage/s3.js'; + +export const resizer = new Resizer({ + // Local filesystem — swap for `new S3Storage({ bucketPublic, publicBaseUrl, client })` + // when you have buckets. No queue/worker in eager mode. + storage: new LocalFsStorage({ + rootDir: './var/media', + publicBaseUrl: '/media', + }), + pipelines: { + default: {}, // add named pipelines, e.g. listing: { beforeSteps: [...] }, premium: { variantSteps: [...] } + }, + // hooks: { formatPublicUrls: (decision, ctx) => formatPictureUrls(decision, { id: String(ctx.id) }) }, +}); diff --git a/src/scaffold/templates/resizer.ts.tpl b/src/scaffold/templates/resizer.ts.tpl index 89ecf4c..4182b31 100644 --- a/src/scaffold/templates/resizer.ts.tpl +++ b/src/scaffold/templates/resizer.ts.tpl @@ -8,17 +8,19 @@ // Everything below is wired EXCEPT `storage` (REQUIRED): fill the storage TODO and you're done. import { Resizer } from '@adaptivestone/framework-module-resize'; import { MongoTransport } from '@adaptivestone/framework-module-resize/transports/mongo.js'; -// S3 / S3-compatible storage (install the optional AWS peers first — 05 · §10.5): +// Local filesystem (tests / first-week local) or S3 (install the optional AWS peers first): +// import { LocalFsStorage } from '@adaptivestone/framework-module-resize/storage/fs.js'; // import { S3Storage } from '@adaptivestone/framework-module-resize/storage/s3.js'; export const resizer = new Resizer({ transport: new MongoTransport(), // or new SqsTransport({ queueUrl, region }); omit for eager-only (11 · Modes) - // TODO(REQUIRED): provide a storage driver — e.g. `new S3Storage({ bucketPublic: '…', publicUrl: '…' })` - // (uncomment the import above) or your own ResizeStorage (05 · §10.4). Until then tsc fails with + // TODO(REQUIRED): provide a storage driver — e.g. `new LocalFsStorage({ rootDir: './var/media', publicBaseUrl: '/media' })` + // or `new S3Storage({ bucketPublic: '…', publicBaseUrl: '…', client })` (uncomment an import above) + // or your own ResizeStorage (05 · §10.4). Until then tsc fails with // "Cannot find name 'PROVIDE_YOUR_STORAGE_DRIVER'" — a loud, named reminder (see README). storage: PROVIDE_YOUR_STORAGE_DRIVER, pipelines: { default: {}, // add named pipelines, e.g. listing: { beforeSteps: [...] }, premium: { variantSteps: [...] } }, - // hooks: { resolveSizes: (sizes, ctx) => sizes, formatPublicUrls: (decision, ctx) => decision }, + // hooks: { formatPublicUrls: (decision, ctx) => formatPictureUrls(decision, { id: String(ctx.id) }) }, }); diff --git a/src/storage/fs.test.ts b/src/storage/fs.test.ts new file mode 100644 index 0000000..128a15d --- /dev/null +++ b/src/storage/fs.test.ts @@ -0,0 +1,73 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, readFile, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, test } from 'node:test'; +import { LocalFsStorage } from './fs.ts'; + +const fresh = () => mkdtemp(join(tmpdir(), 'resize-fs-')); + +describe('LocalFsStorage.upload / download', () => { + test('round-trips a buffer under rootDir/key and creates parent dirs', async () => { + const dir = await fresh(); + const s = new LocalFsStorage({ rootDir: dir, publicBaseUrl: '/media' }); + const body = Buffer.from('hello-preview'); + const ref = await s.upload({ + key: 'uploads/a/b.jpg', + body, + contentType: 'image/jpeg', + visibility: 'public', + }); + assert.deepEqual(ref, { key: 'uploads/a/b.jpg' }); + const onDisk = await readFile(join(dir, 'uploads/a/b.jpg')); + assert.deepEqual(onDisk, body); + const downloaded = await s.download(ref); + assert.deepEqual(downloaded, body); + }); + + test('download reads a file the host already placed at original.key', async () => { + const dir = await fresh(); + await writeFile(join(dir, 'orig.png'), Buffer.from('orig')); + const s = new LocalFsStorage({ rootDir: dir, publicBaseUrl: '/media' }); + const buf = await s.download({ key: 'orig.png' }); + assert.deepEqual(buf, Buffer.from('orig')); + }); +}); + +describe('LocalFsStorage.publicUrl', () => { + test('joins publicBaseUrl + key and trims slashes', () => { + const s = new LocalFsStorage({ + rootDir: '/tmp/x', + publicBaseUrl: 'https://cdn.example.com/media/', + }); + assert.equal( + s.publicUrl({ key: 'uploads/a.jpg' }), + 'https://cdn.example.com/media/uploads/a.jpg', + ); + assert.equal( + s.publicUrl({ key: '/uploads/a.jpg' }), + 'https://cdn.example.com/media/uploads/a.jpg', + ); + }); +}); + +describe('LocalFsStorage path traversal', () => { + test('refuses a key that escapes rootDir', async () => { + const dir = await fresh(); + const s = new LocalFsStorage({ rootDir: dir, publicBaseUrl: '/media' }); + await assert.rejects( + () => + s.upload({ + key: '../outside.jpg', + body: Buffer.from('x'), + contentType: 'image/jpeg', + visibility: 'public', + }), + /escapes rootDir/, + ); + await assert.rejects( + () => s.download({ key: '../../etc/passwd' }), + /escapes rootDir/, + ); + }); +}); diff --git a/src/storage/fs.ts b/src/storage/fs.ts new file mode 100644 index 0000000..f061036 --- /dev/null +++ b/src/storage/fs.ts @@ -0,0 +1,66 @@ +// Local filesystem storage (0.2 adoption) — default story for tests and first-week +// local. Same ResizeStorage contract as S3; no optional peers. SUBPATH-ONLY ENTRY +// (`…/storage/fs.js`), same as the other drivers. +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { dirname, isAbsolute, relative, resolve } from 'node:path'; +import type { StorageRef } from '../types.d.ts'; +import type { ResizeStorage } from './AbstractStorage.ts'; + +export interface LocalFsStorageOptions { + rootDir: string; // files land under this directory + publicBaseUrl: string; // URL prefix for publicUrl(), e.g. '/media' or 'http://localhost:3000/media' +} + +/** Resolve `key` under `rootDir`; throw if it escapes the root (path traversal). */ +function resolveInsideRoot(rootDir: string, key: string): string { + if (!key || key.includes('\0')) { + throw new Error('resize fs: invalid storage key'); + } + const root = resolve(rootDir); + const abs = resolve(root, key); + const rel = relative(root, abs); + if (rel === '' || rel.startsWith('..') || isAbsolute(rel)) { + throw new Error( + `resize fs: key "${key}" escapes rootDir — refusing path traversal`, + ); + } + return abs; +} + +export class LocalFsStorage implements ResizeStorage { + private readonly rootDir: string; + private readonly publicBaseUrl: string; + + constructor(opts: LocalFsStorageOptions) { + this.rootDir = opts.rootDir; + this.publicBaseUrl = opts.publicBaseUrl; + } + + async download(ref: StorageRef): Promise { + return readFile(resolveInsideRoot(this.rootDir, ref.key)); + } + + async upload({ + key, + body, + }: { + key: string; + body: Buffer | Uint8Array; + contentType: string; + // Accepted to match ResizeStorage; local/dev shares one tree (not a private store). + visibility: 'public' | 'private'; + }): Promise { + const abs = resolveInsideRoot(this.rootDir, key); + await mkdir(dirname(abs), { recursive: true }); + await writeFile(abs, body); + return { key }; + } + + // PURE string building — no I/O (called on the read path). Option is publicBaseUrl + // (never `publicUrl`) so it cannot shadow this method name. + publicUrl(ref: StorageRef): string { + const base = this.publicBaseUrl.replace(/\/+$/, ''); + const key = ref.key.replace(/^\/+/, ''); + return `${base}/${key}`; + } +} diff --git a/src/storage/s3.test.ts b/src/storage/s3.test.ts index 554ee46..16c23d7 100644 --- a/src/storage/s3.test.ts +++ b/src/storage/s3.test.ts @@ -171,6 +171,26 @@ describe('S3Storage.publicUrl (pure — no client)', () => { ); }); + test('publicBaseUrl is the preferred option (alias of the deprecated publicUrl)', () => { + const s = new S3Storage({ + bucketPublic: 'pub', + publicBaseUrl: 'https://cdn.example.com/', + }); + assert.equal( + s.publicUrl({ key: 'a/b.jpg' }), + 'https://cdn.example.com/a/b.jpg', + ); + }); + + test('publicBaseUrl wins when both publicBaseUrl and publicUrl are set', () => { + const s = new S3Storage({ + bucketPublic: 'pub', + publicBaseUrl: 'https://new.example.com', + publicUrl: 'https://old.example.com', + }); + assert.equal(s.publicUrl({ key: 'k' }), 'https://new.example.com/k'); + }); + test('endpoint + forcePathStyle → path-style URL', () => { const s = new S3Storage({ bucketPublic: 'pub', diff --git a/src/storage/s3.ts b/src/storage/s3.ts index 7e6cf87..ab92a25 100644 --- a/src/storage/s3.ts +++ b/src/storage/s3.ts @@ -22,7 +22,9 @@ import type { ResizeStorage } from './AbstractStorage.ts'; export interface S3StorageOptions { bucketPublic: string; // previews land here (upload visibility 'public') bucketPrivate?: string; // originals ('private'); defaults to bucketPublic - publicUrl?: string; // CDN/base URL for public objects, e.g. 'https://cdn.example.com' + publicBaseUrl?: string; // CDN/base URL for public objects, e.g. 'https://cdn.example.com' + /** @deprecated Use `publicBaseUrl`. Same string; kept for one minor. */ + publicUrl?: string; region?: string; endpoint?: string; // S3-compatible: MinIO / localstack / R2 forcePathStyle?: boolean; @@ -131,8 +133,9 @@ export class S3Storage implements ResizeStorage { publicUrl(ref: StorageRef): string { this.assertAllowedBucket(ref.bucket); const bucket = ref.bucket ?? this.opts.bucketPublic; - if (this.opts.publicUrl) { - return `${this.opts.publicUrl.replace(/\/+$/, '')}/${ref.key}`; + const publicBase = this.opts.publicBaseUrl ?? this.opts.publicUrl; + if (publicBase) { + return `${publicBase.replace(/\/+$/, '')}/${ref.key}`; } if (this.opts.endpoint !== undefined || this.opts.forcePathStyle) { const base = (this.opts.endpoint ?? '').replace(/\/+$/, ''); diff --git a/src/types.d.ts b/src/types.d.ts index fabd32d..2e51e0e 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -117,6 +117,7 @@ export interface ReadyEntry { url: string; preview?: Preview; // present for generated previews; ABSENT for original-backed entries isOriginal?: boolean; // true when `url` points at the untouched original + contentType?: string; // preview.contentType, or original.contentType when isOriginal } export interface ReadDecision { @@ -124,6 +125,18 @@ export interface ReadDecision { missing: MissingPreview[]; } +// Generic `` map produced by `formatPictureUrls` (0.2). A convenience — not +// "the" host contract. `sizeKey` is whatever identity already is (`720x720`, `620w`, `fit`). +export interface PictureUrls { + mediaType?: string; + id?: string; + sizes: { + [sizeKey: string]: { + [format: string]: { url: string; contentType?: string }; + }; + }; +} + // --------------------------------------------------------------------------- // Config (merged with app.getConfig('resize') — see 08 · §13) //