image-renditions 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -8,10 +8,10 @@ Resize and re-encode uploaded images when a page first shows them, store each re
8
8
 
9
9
  ```html
10
10
  <img
11
- src="/_renditions/v1/avatar.faf7e221f65dd97945e352a57e805252.avif/avatars/42.jpg"
11
+ src="/_renditions/v1/avatar.faf7e221f65d.avif/avatars/42.jpg"
12
12
  srcset="
13
- /_renditions/v1/avatar.faf7e221f65dd97945e352a57e805252.avif/avatars/42.jpg 1x,
14
- /_renditions/v1/avatar@2x.b62b90cf6c3ed57e4c1c27da0c36d778.avif/avatars/42.jpg 2x
13
+ /_renditions/v1/avatar.faf7e221f65d.avif/avatars/42.jpg 1x,
14
+ /_renditions/v1/avatar@2x.b62b90cf6c3e.avif/avatars/42.jpg 2x
15
15
  "
16
16
  alt=""
17
17
  />
@@ -23,7 +23,13 @@ Resize and re-encode uploaded images when a page first shows them, store each re
23
23
 
24
24
  With an S3 bucket, or a directory that outlives deploys and is shared by every replica, app servers keep no image files: renditions survive redeploys, and any replica serves any of them.
25
25
 
26
- The h3 handler supports h3 1.15 or newer and h3 2, and the Nuxt module requires Nuxt 4.
26
+ The library is modular, with pluggable parts:
27
+
28
+ - [HTTP](#h3-and-other-frameworks): an event handler for h3 1.15 or newer and h3 2, and a framework-neutral handler for other servers.
29
+ - [Processing](#processing): a processor built on sharp.
30
+ - [Storage](#storage): an S3 bucket or a file system directory, chosen separately for uploads and renditions.
31
+ - [Nuxt](#quick-start): a Nuxt 4 module that registers the handler and the `<RenditionImg>` component.
32
+ - [Extending](#extending): other storage, image libraries, and state shared between replicas plug in through the interfaces of `image-renditions/server`.
27
33
 
28
34
  ## When not to use it
29
35
 
@@ -62,7 +68,7 @@ Create a handler for rendition URLs, for example one that reads uploads from the
62
68
 
63
69
  ```ts
64
70
  // server/renditions.ts
65
- import { S3 } from "@aws-sdk/client-s3"
71
+ import { S3Client } from "@aws-sdk/client-s3"
66
72
  import { defineRenditionEventHandler } from "image-renditions/h3"
67
73
  import { createS3Results, createS3Source } from "image-renditions/s3"
68
74
  import { createRenditionServer } from "image-renditions/server"
@@ -70,9 +76,10 @@ import { createSharpProcessor } from "image-renditions/sharp"
70
76
 
71
77
  import catalog from "#shared/renditions"
72
78
 
73
- const client = new S3({ region: "us-east-1" })
79
+ const client = new S3Client({ region: "us-east-1" })
74
80
 
75
81
  const server = createRenditionServer({
82
+ catalog,
76
83
  source: createS3Source({ client, bucket: "uploads" }),
77
84
  results: createS3Results({
78
85
  client,
@@ -82,7 +89,7 @@ const server = createRenditionServer({
82
89
  processor: createSharpProcessor(),
83
90
  })
84
91
 
85
- export default defineRenditionEventHandler({ catalog, server })
92
+ export default defineRenditionEventHandler({ server })
86
93
  ```
87
94
 
88
95
  A rendition of `avatars/42.jpg` is stored in `renditions` at `avatars/42.jpg/<hash>.avif`, and `publicUrl` is where browsers load it from.
@@ -129,6 +136,8 @@ Options of a preset, or of `defaults`, which every preset overrides field by fie
129
136
  - `quality` — encoder quality from 1 to 100, by default 50 for `avif` and 80 for `webp` and `jpeg`; `png` is lossless.
130
137
  - `effort` — CPU effort of the `avif` (0–9) and `webp` (0–6) encoders, 4 by default; other formats ignore it.
131
138
  - `densities` — pixel densities to render, `[1]` by default.
139
+ - `animation` — `first-frame` (default) renders the first frame of animated sources, and `preserve` keeps the animation of GIF and WebP sources; see [Animation](#animation).
140
+ - `originals` — extensions of sources shown as they are, so no option above applies to them; see [Originals](#originals).
132
141
 
133
142
  Options of the catalog:
134
143
 
@@ -143,11 +152,13 @@ Renaming a preset keeps its stored renditions.
143
152
  `<RenditionImg>` renders `src`, and a `srcset` when the preset has several densities.
144
153
 
145
154
  - It also renders `width` and `height` when the image always has the preset size: with `contain`, or with `cover` and `fill` when `enlarge` is set.
146
- Otherwise a small source keeps its own size, so size such images with CSS.
155
+ It renders neither for other presets, including ones that set a single side, so size their images with CSS.
147
156
  - A `src` that is already a URL, such as `blob:` or `https:`, renders unchanged, for example a preview of a file being uploaded.
148
- - A path no rendition URL can address, such as one with a `..` segment, renders no `src` instead of breaking the page.
157
+ - An unsafe path, such as one with a `..` segment, renders no `src` instead of breaking the page.
158
+ - A source whose extension the preset lists in [`originals`](#originals) renders as it is.
159
+ - A GIF or WebP source of a preset with [`animation: "preserve"`](#animation) renders its animated variants.
149
160
 
150
- `useRenditions()` builds the same URLs in code, prefixed with `app.baseURL`:
161
+ `useRenditions()` builds the same URLs in code, prefixing rendition URLs with `app.baseURL`:
151
162
 
152
163
  ```ts
153
164
  const renditions = useRenditions()
@@ -155,14 +166,19 @@ const renditions = useRenditions()
155
166
  renditions.url("cover", post.coverPath)
156
167
  renditions.url("avatar", path, { density: 2 })
157
168
  renditions.srcset("avatar", path)
158
- renditions.attrs("avatar", path) // attributes <RenditionImg> renders, for your own img element
169
+ renditions.attrs("avatar", path) // src, srcset and size of <RenditionImg>, for your own img element
159
170
  ```
160
171
 
172
+ `url()` and `srcset()` return `undefined` for an unsafe path.
173
+
161
174
  Module options, under `renditions`:
162
175
 
163
176
  - `catalog` — module exporting the catalog by default; required.
164
177
  - `handler` — optional module exporting the h3 handler by default, mounted at the catalog base path.
165
178
  - `defaultScope` — optional [scope](#scopes) for `<RenditionImg>` and `useRenditions()`.
179
+ - `originalUrl` — module building URLs of [originals](#originals); required when a preset lists `originals`.
180
+ - `imgAttrs` — attributes every `<RenditionImg>` renders, such as `{ loading: "lazy", decoding: "async" }`.
181
+ An attribute of the element overrides them, for example `loading="eager"` on an image above the fold.
166
182
 
167
183
  > For users coming from `@nuxt/image`: this module is not an `@nuxt/image` provider, so instead of `<NuxtImg>` and `useImage()`, use `<RenditionImg>` and `useRenditions()` with catalog presets.
168
184
 
@@ -180,25 +196,37 @@ Scopes and `resolveSource` let the server decide where a path lives:
180
196
 
181
197
  Without `resolveSource`, an unscoped path is the key itself, and scoped paths are rejected.
182
198
 
183
- For example, with uploads of several tenants and files shared between them:
199
+ For example, with uploads of several tenants and files shared between them, declare both scopes in the catalog:
200
+
201
+ ```ts
202
+ // shared/renditions.ts
203
+ export default defineCatalog({
204
+ scopes: ["tenant", "shared"],
205
+ presets: {
206
+ // ...
207
+ },
208
+ })
209
+ ```
210
+
211
+ - Scope names are type-checked against the catalog, like preset names.
212
+ - `catalog.url()` requires a scope.
213
+ `useRenditions()` and `<RenditionImg>` use the module's `defaultScope` when a call has no scope, and throw a `RangeError` when there is neither.
214
+ - The server rejects a target with another scope or no scope as `invalid-request`, so `resolveSource` receives only the declared scopes.
215
+ - `ScopeName<typeof catalog>` is the union of the scopes, and `PresetName<typeof catalog>` is the union of the preset names.
216
+
217
+ The catalog of the server types the scopes `resolveSource` receives, so it handles each of them:
184
218
 
185
219
  ```ts
186
220
  // server/renditions.ts
187
- const server = createRenditionServer<{ tenant: string }>({
221
+ const server = createRenditionServer({
222
+ catalog,
188
223
  // source, results and processor as in the quick start
189
- resolveSource({ scope, path }, { tenant }) {
190
- if (scope === "tenant") {
191
- return { sourceKey: `tenants/${tenant}/${path}` }
192
- }
193
- if (scope === "shared") {
194
- return { sourceKey: `shared/${path}` }
195
- }
196
- return undefined
224
+ resolveSource({ scope, path }, { tenant }: { tenant: string }) {
225
+ return { sourceKey: scope === "tenant" ? `tenants/${tenant}/${path}` : `shared/${path}` }
197
226
  },
198
227
  })
199
228
 
200
229
  export default defineRenditionEventHandler({
201
- catalog,
202
230
  server,
203
231
  // findTenant() is your lookup, for example by host.
204
232
  getContext: async event => ({ tenant: await findTenant(event) }),
@@ -206,26 +234,112 @@ export default defineRenditionEventHandler({
206
234
  ```
207
235
 
208
236
  - If `resolveSource` returns `undefined`, the request fails with `404`.
209
- - `resolveSource` receives unscoped paths too, with an undefined `scope`.
237
+ - Without scopes in the catalog, `resolveSource` receives any scope a URL has, or none, and rejects the ones it does not serve.
210
238
  - Map different scopes to different keys even for equal paths.
211
239
  - A rendition is stored in the result store at `<sourceKey>/<hash>.<extension>`.
212
240
  To keep renditions in the bucket of uploads, also return a `resultPrefix` that no source key starts with, such as `` `renditions/${sourceKey}` ``, to use instead of `sourceKey`.
213
- - S3 limits a key to 1024 bytes in UTF-8, and a rendition key adds up to 38 bytes to its result prefix.
241
+ - S3 limits a key to 1024 bytes in UTF-8, and the library writes a rendition under a key up to 18 bytes longer than its result prefix.
214
242
  If source keys can come that close to the limit, return a shorter `resultPrefix`, or their renditions fail as `invalid-request`.
215
243
 
216
244
  Derive the context from the URL only, host included, for example from the tenant's domain: browsers and CDNs cache the redirect to a rendition by its URL for a day.
217
245
  If the context depends on cookies or a session, pass `redirectCacheControl: "no-store"` to `defineRenditionEventHandler()`, or one tenant may get the rendition of another.
218
246
 
219
- With the Nuxt module, `defaultScope: "tenant"` makes `tenant` the scope of every image, and `scope` selects another one:
247
+ With the Nuxt module, `defaultScope: "tenant"` makes `tenant` the scope of every image, and `scope` selects another one; the build fails when the catalog declares scopes without the default one:
220
248
 
221
249
  ```vue
222
250
  <RenditionImg preset="cover" :src="template.coverPath" scope="shared" alt="" />
223
251
  ```
224
252
 
253
+ ## Originals
254
+
255
+ Some sources are better shown as they are: SVG stays crisp at any zoom, and [`createSharpProcessor()`](#processing) cannot decode ICO or BMP.
256
+ `originals` lists the extensions of such sources, in lowercase and without the dot:
257
+
258
+ ```ts
259
+ export default defineCatalog({
260
+ defaults: { originals: ["svg", "ico", "bmp"] },
261
+ presets: {
262
+ logo: { height: 32 },
263
+ // Email clients do not show SVG.
264
+ "email-logo": { height: 32, originals: [] },
265
+ },
266
+ })
267
+ ```
268
+
269
+ For such a path, `<RenditionImg>` and `useRenditions()` give the URL of the source itself:
270
+
271
+ - `url()` returns it for every declared density, and `srcset()` returns `undefined`.
272
+ - `<RenditionImg>` renders no `srcset`, `width` or `height`, so size such images with CSS.
273
+ - `gif` in the list shows every GIF as it is, because a path does not tell an animated GIF from a still one.
274
+ Leave it out and set [`animation: "preserve"`](#animation) instead to shrink animated GIFs.
275
+ - Only the extension is checked, not the content, and the server still renders the source when its rendition URL is requested.
276
+ - Changing `originals` keeps the hashes of the preset.
277
+
278
+ Only the app knows where its sources are served from, so it builds their URLs.
279
+ Set the module option `originalUrl` to a module whose default export is a composable returning a function from the scope and the logical path to the URL:
280
+
281
+ ```ts
282
+ // app/renditions/original-url.ts
283
+ import { encodePath, type RenditionTarget } from "image-renditions"
284
+
285
+ export default function useOriginalUrl() {
286
+ const { uploadsUrl } = useRuntimeConfig().public
287
+ return ({ path }: RenditionTarget) => `${uploadsUrl}/${encodePath(path)}`
288
+ }
289
+ ```
290
+
291
+ ```ts
292
+ // nuxt.config.ts
293
+ export default defineNuxtConfig({
294
+ renditions: {
295
+ catalog: "#shared/renditions",
296
+ originalUrl: "~/renditions/original-url",
297
+ },
298
+ })
299
+ ```
300
+
301
+ - `useRenditions()` calls the composable every time it is called, so the composable may use other composables.
302
+ - The function receives the scope after `defaultScope`, and a path that is safe but not encoded: `encodePath()` keeps characters such as `#` and `?` inside the path.
303
+ - The URL it returns is used as is, without `app.baseURL`.
304
+ - The build fails when a preset lists `originals` and `originalUrl` is not set.
305
+
306
+ > `<img>` never runs scripts of an SVG, but an SVG opened as a page runs them on the site it is served from.
307
+ > Keep SVG files that users upload from running scripts as part of the app in any of these ways:
308
+ >
309
+ > - Serve them from a separate domain rather than a subdomain of the app.
310
+ > - Serve them with a `Content-Security-Policy: sandbox` response header.
311
+ > - Store them with `Content-Disposition: attachment`, which S3 keeps with the object, so browsers download such a file instead of opening it.
312
+
313
+ ## Animation
314
+
315
+ A rendition of an animated source shows its first frame.
316
+ `animation: "preserve"` keeps the animation of GIF and WebP sources instead:
317
+
318
+ ```ts
319
+ export default defineCatalog({
320
+ presets: {
321
+ thumb: { width: 96, height: 96 },
322
+ content: { width: 800, densities: [1, 2], animation: "preserve" },
323
+ },
324
+ })
325
+ ```
326
+
327
+ - A path with the `gif` or `webp` extension gets variants of its own, which `url()`, `srcset()` and `<RenditionImg>` choose by the extension, as a path does not tell an animated source from a still one.
328
+ - These variants are WebP and take `quality` and `effort` only from a preset whose `format` is `webp`, since values for another format do not suit WebP.
329
+ - A still GIF or WebP source renders as a still WebP image.
330
+ - Only the extension is checked, and the server renders any source with these variants when their URL is requested.
331
+ - The variants of other sources keep their hashes, so only GIF and WebP sources render again.
332
+ - [`originals`](#originals) take precedence, so a preset that lists `gif` in them shows GIF sources as they are.
333
+
334
+ To render the animated variants ahead of a request, pass the source path to `catalog.variants()`, as in [Rendering outside requests](#rendering-outside-requests).
335
+
336
+ How much of an animation a rendition keeps depends on the processor; see [Processing](#processing) for `createSharpProcessor()`.
337
+ `createRenditionServer()` fails when its processor cannot render a variant of the catalog.
338
+
225
339
  ## Storage
226
340
 
227
341
  The server reads sources from a source store and writes renditions to a result store, which may be of different kinds.
228
- Both source stores fail sources larger than 50 MiB as unsupported; pass `maxBytes` to `createS3Source()` or `createFileSystemSource()` to change the limit.
342
+ Both source stores fail sources larger than 50 MiB as `source-too-large`; pass `maxBytes` to `createS3Source()` or `createFileSystemSource()` to change the limit.
229
343
 
230
344
  ### S3 storage
231
345
 
@@ -233,12 +347,13 @@ To keep uploads and renditions in S3 buckets, use the stores of `image-rendition
233
347
 
234
348
  ```ts
235
349
  // server/renditions.ts
236
- import { S3 } from "@aws-sdk/client-s3"
350
+ import { S3Client } from "@aws-sdk/client-s3"
237
351
  import { createS3Results, createS3Source } from "image-renditions/s3"
238
352
 
239
- const client = new S3({ region: "us-east-1" })
353
+ const client = new S3Client({ region: "us-east-1" })
240
354
 
241
355
  const server = createRenditionServer({
356
+ catalog,
242
357
  source: createS3Source({ client, bucket: "uploads" }),
243
358
  results: createS3Results({
244
359
  client,
@@ -249,7 +364,7 @@ const server = createRenditionServer({
249
364
  })
250
365
  ```
251
366
 
252
- The stores take the aggregated `S3` client of AWS SDK v3 or any object with its `getObject`, `headObject` and `putObject` methods.
367
+ The stores take a client of AWS SDK v3, the bare `S3Client` or the aggregated `S3`.
253
368
  `createS3Results()` writes renditions with `Cache-Control: public, max-age=31536000, immutable`; pass it `cacheControl` to change the header.
254
369
  The bucket of renditions must serve them publicly at `publicUrl`, directly or through a CDN.
255
370
 
@@ -262,6 +377,7 @@ To keep uploads and renditions on a disk served by a web server, use the stores
262
377
  import { createFileSystemResults, createFileSystemSource } from "image-renditions/fs"
263
378
 
264
379
  const server = createRenditionServer({
380
+ catalog,
265
381
  source: createFileSystemSource({ root: "/data/uploads" }),
266
382
  results: createFileSystemResults({ root: "/data/renditions", publicUrl: "/renditions" }),
267
383
  processor: createSharpProcessor(),
@@ -278,10 +394,21 @@ location /renditions/ {
278
394
  ## Processing
279
395
 
280
396
  `createSharpProcessor()` from `image-renditions/sharp` processes images with [sharp](https://sharp.pixelplumbing.com).
281
- Sources over 100 megapixels fail as unsupported; pass `maxInputPixels` to `createSharpProcessor()` to change the limit.
397
+ Sources over 100 megapixels fail as `source-too-large`; pass `maxInputPixels` to `createSharpProcessor()` to change the limit, or `0` to remove it.
282
398
  A source that a preset would scale beyond 16383 pixels on a side, such as a narrow strip enlarged to the preset width, is scaled less to stay within that limit, so its rendition is smaller than the preset sets.
283
399
  Under `cover`, such a source is cropped instead, and its rendition keeps the preset size.
284
400
 
401
+ `createSharpProcessor()` keeps the [animation](#animation) of GIF and WebP sources.
402
+ It does not crop animations by `entropy` or `attention`, so a server fails to start with a preset that combines them.
403
+ It renders the first frame of other animated sources, such as APNG and animated AVIF, of an animation with an EXIF orientation, and of an animation beyond a limit:
404
+
405
+ - `maxFrames` — most frames, 1000 by default.
406
+ - `maxAnimationPixels` — most pixels of all frames, each counted at the larger of the source and the rendition size, 50 megapixels by default.
407
+
408
+ `0` removes a limit.
409
+ An animation at the pixel limit may take about 4 bytes of memory per pixel, and its encoder needs more, so plan memory for `limits.concurrency` such renditions at once.
410
+ Changing the limits keeps stored renditions as they are; bump `pipelineVersion` to render them again.
411
+
285
412
  To process images with another library, implement the [`Processor`](#extending) interface and pass your implementation to `createRenditionServer()` as `processor`.
286
413
 
287
414
  ## h3 and other frameworks
@@ -307,7 +434,7 @@ It takes the same options without `getContext` and returns a function from the r
307
434
  // server.ts
308
435
  import { createRenditionHandler } from "image-renditions/server"
309
436
 
310
- const renditions = createRenditionHandler({ catalog, server })
437
+ const renditions = createRenditionHandler({ server })
311
438
 
312
439
  export default {
313
440
  fetch(request: Request) {
@@ -322,21 +449,39 @@ Build URLs in the app with the catalog, which is safe to import in browsers:
322
449
 
323
450
  ```ts
324
451
  catalog.url("avatar", { path: "avatars/42.jpg" })
325
- // /_renditions/v1/avatar.faf7e221f65dd97945e352a57e805252.avif/avatars/42.jpg
452
+ // /_renditions/v1/avatar.faf7e221f65d.avif/avatars/42.jpg
326
453
 
327
454
  catalog.srcset("avatar", { scope: "tenant", path: "avatars/42.jpg" })
328
455
  ```
329
456
 
457
+ They throw a `RangeError` for an unsafe path; check stored paths with `isSafePath()` first.
458
+ With [originals](#originals) in the catalog, check `catalog.isOriginal()` and build the URL of such a source yourself:
459
+
460
+ ```ts
461
+ function logoUrl(path: string) {
462
+ if (!isSafePath(path)) {
463
+ return undefined
464
+ }
465
+ if (catalog.isOriginal("logo", path)) {
466
+ return `https://uploads.example.com/${encodePath(path)}`
467
+ }
468
+ return catalog.url("logo", { path })
469
+ }
470
+ ```
471
+
330
472
  ## Rendering outside requests
331
473
 
332
474
  `server.ensure()` publishes one density of a preset without a request, for example right after an upload:
333
475
 
334
476
  ```ts
335
- for (const { recipe } of catalog.variants("avatar")) {
336
- await server.ensure(recipe, { scope: "tenant", path: "avatars/42.jpg" }, { tenant: "acme" })
477
+ const path = "avatars/42.jpg"
478
+ for (const { recipe } of catalog.variants("avatar", path)) {
479
+ await server.ensure(recipe, { scope: "tenant", path }, { tenant: "acme" })
337
480
  }
338
481
  ```
339
482
 
483
+ The path chooses the [animated](#animation) variants of sources that have them.
484
+
340
485
  It resolves to the key and public URL of the rendition after it is written or found in storage.
341
486
 
342
487
  ## How a request is served
@@ -383,7 +528,8 @@ The handler responds to a failure with the status of its reason:
383
528
  - `invalid-request` (404): malformed URL, `resolveSource` rejected the target, or a key is unsafe or too long for the store
384
529
  - `unknown-recipe` (404): hash neither in the catalog nor known as published
385
530
  - `source-not-found` (404): no source under the resolved key
386
- - `unsupported-source` (422): source cannot be decoded or rendered, or exceeds a size limit
531
+ - `unsupported-source` (422): source cannot be decoded or processed
532
+ - `source-too-large` (422): source exceeds a byte or pixel limit
387
533
  - `source-error` (502): reading the source failed
388
534
  - `processing-failed` (500): processing failed for a reason other than the source
389
535
  - `storage-error` (502): checking for or writing the rendition failed
@@ -401,22 +547,25 @@ Otherwise the request fails as `unknown-recipe` without calling the stores, so m
401
547
 
402
548
  ```ts
403
549
  export default defineRenditionEventHandler({
404
- catalog,
405
550
  server,
406
551
  getContext,
407
- onFailure({ reason, error, address, context }) {
552
+ async onFailure({ reason, error, target, context }) {
408
553
  if (reason === "storage-error" || reason === "processing-failed") {
409
554
  reportError(error)
410
555
  }
411
- if (reason === "unknown-recipe" && address) {
412
- // originalUrl() is your absolute URL of the uploaded file at the scope and path of the address.
413
- return Response.redirect(originalUrl(address, context), 302)
556
+ if (reason === "unknown-recipe" && target) {
557
+ // Here sources are public, so an outdated URL shows the original instead.
558
+ const { sourceKey } = await server.resolve(target, context)
559
+ return Response.redirect(`https://uploads.example.com/${encodePath(sourceKey)}`, 302)
414
560
  }
415
561
  return undefined
416
562
  },
417
563
  })
418
564
  ```
419
565
 
566
+ - `target` is the target of the URL, absent when the URL is malformed or its scope is not declared.
567
+ - `server.resolve()` returns the keys a target resolves to with `resolveSource`, after checking that they are safe.
568
+
420
569
  Returning `undefined` keeps the default response.
421
570
  Every failure response, including a replaced one, is sent with `Cache-Control: no-store`.
422
571
 
@@ -426,6 +575,7 @@ Implement an interface from `image-renditions/server` and pass your implementati
426
575
 
427
576
  - To use other storage, implement `SourceStore` or `ResultStore` and pass it as `source` or `results`.
428
577
  - To process images with another library, implement `Processor` and pass it as `processor`.
578
+ Implement its `assertRecipe()` to reject recipes it cannot render, such as animations, when the server is created.
429
579
  - To share the index of published renditions between replicas, for example in Redis, implement `ReadinessIndex` and pass it as `readiness.shared`.
430
580
  If a call fails or exceeds `indexTimeout`, the server reports the error to `onError` and checks storage instead, while [outdated URLs](#outdated-urls) fail as `unknown-recipe`.
431
581
  - To render each rendition once across replicas, implement `Coordinator` as a shared lock and pass it as `coordinator`.
@@ -439,6 +589,7 @@ The built-in stores and processor implement the same interfaces and serve as exa
439
589
  Keep private images out of what `resolveSource` maps, for example by serving only the folders of public images.
440
590
  - A source must never change under its key: renditions of a replaced source keep showing the old image.
441
591
  Store new content under a new key.
442
- - Deleting a source leaves its renditions in storage under its result prefix.
443
- - Animated sources render their first frame, SVG sources are rasterized at the target size, and EXIF orientation is applied.
592
+ - Deleting a source leaves its renditions in storage under its result prefix, which `server.resolve()` returns.
593
+ - A rendition of an animated source shows its first frame unless the preset sets [`animation: "preserve"`](#animation), a rendition of an SVG source is rasterized at the target size, and EXIF orientation is applied.
594
+ A preset shows such sources as they are when it lists their extensions in [`originals`](#originals).
444
595
  - Source paths are limited to 1024 characters.
@@ -0,0 +1,130 @@
1
+ import { a as RenditionAnimation, c as RenditionOptions, r as Recipe, u as SourcePolicy } from "./recipe-BT8D60Qb.mjs";
2
+ //#region src/catalog.d.ts
3
+ /** Named transformation in a catalog. */
4
+ interface PresetDefinition extends RenditionOptions {
5
+ /** Pixel densities to render, as multipliers of `width` and `height`; defaults to `[1]`. */
6
+ densities?: readonly number[];
7
+ /**
8
+ * Extensions of source files shown as they are instead of rendered, such as `svg`, in lowercase and without the dot.
9
+ *
10
+ * Such sources are shown as they are whatever the other options, including `animation`.
11
+ * They do not affect hashes, and the server still renders such a source when its rendition URL is requested.
12
+ */
13
+ originals?: readonly string[];
14
+ /**
15
+ * How renditions show animated sources; `first-frame` by default.
16
+ *
17
+ * With `preserve`, sources with the `gif` or `webp` extension get variants of their own in `webp` that keep the animation, which take `quality` and `effort` only from a `webp` preset.
18
+ * The variants of other sources keep their hashes.
19
+ */
20
+ animation?: RenditionAnimation;
21
+ }
22
+ /** Shared configuration of renditions, imported by both the client and the server. */
23
+ interface CatalogDefinition<Name extends string = string, Scope extends string = string> {
24
+ /** Path the rendition handler is mounted at, `/_renditions` by default. */
25
+ basePath?: string;
26
+ /** Bumping it gives every variant a new hash, so every rendition is generated again. */
27
+ pipelineVersion?: number;
28
+ /** Options of every preset; a preset overrides them field by field. */
29
+ defaults?: PresetDefinition;
30
+ /** How special kinds of sources are rendered. */
31
+ sources?: Partial<SourcePolicy>;
32
+ /**
33
+ * Scopes a logical path can belong to, such as `tenant`; every target then has one of them.
34
+ *
35
+ * Without them, a target may have any scope or none.
36
+ */
37
+ scopes?: readonly Scope[];
38
+ presets: Record<Name, PresetDefinition>;
39
+ }
40
+ /**
41
+ * One density of a preset, which is a separate recipe with its own hash.
42
+ *
43
+ * A preset that preserves animation has a second variant at each density for GIF and WebP sources.
44
+ */
45
+ interface RenditionVariant {
46
+ readonly preset: string;
47
+ readonly density: number;
48
+ /** Readable URL label, such as `avatar` or `avatar@2x`. */
49
+ readonly label: string;
50
+ readonly recipe: Recipe;
51
+ readonly hash: string;
52
+ }
53
+ /**
54
+ * Source a rendition is made from.
55
+ *
56
+ * `Scope` is the union of the scopes a catalog declares, which makes `scope` required, or `string` for a catalog without them.
57
+ */
58
+ type RenditionTarget<Scope extends string = string> = {
59
+ /** Logical source path inside the scope. */
60
+ path: string;
61
+ } & (string extends Scope ? {
62
+ /** Application-defined space of the path, such as a tenant; omitted when the application has one space. */
63
+ scope?: string;
64
+ } : {
65
+ /** Space of the path, one of the scopes of the catalog. */
66
+ scope: Scope;
67
+ });
68
+ /** Validated catalog with its variants and URL builders. */
69
+ interface Catalog<Name extends string = string, Scope extends string = string> {
70
+ readonly basePath: string;
71
+ readonly presets: readonly Name[];
72
+ /** Scopes every target has one of, if the catalog declares them. */
73
+ readonly scopes?: readonly Scope[];
74
+ /**
75
+ * Variants of a preset ordered by density.
76
+ *
77
+ * With the path of a GIF or WebP source of a preset that preserves animation, they are the variants that keep it.
78
+ */
79
+ variants(preset: Name, path?: string): readonly RenditionVariant[];
80
+ /**
81
+ * Variant of a preset at a density, `1` by default, chosen by the source path like in `variants()`.
82
+ *
83
+ * @throws {RangeError} When the preset does not declare the density.
84
+ */
85
+ variant(preset: Name, density?: number, path?: string): RenditionVariant;
86
+ /**
87
+ * Extensions of source files a preset shows as they are instead of rendered.
88
+ *
89
+ * @throws {RangeError} When the preset is unknown.
90
+ */
91
+ originals(preset: Name): readonly string[];
92
+ /**
93
+ * Whether a preset shows a source as it is instead of rendered, judged by the extension of its path.
94
+ *
95
+ * @throws {RangeError} When the preset is unknown.
96
+ */
97
+ isOriginal(preset: Name, path: string): boolean;
98
+ /** Every variant of every preset, one per hash. */
99
+ readonly allVariants: readonly RenditionVariant[];
100
+ /** Variant with a recipe hash, if the catalog has one. */
101
+ findVariant(hash: string): RenditionVariant | undefined;
102
+ /**
103
+ * Root-relative URL of a rendition.
104
+ *
105
+ * @throws {RangeError} When the preset is unknown or does not declare the density, the scope is not one the catalog declares, or the scope or path is unsafe to put into the URL.
106
+ */
107
+ url(preset: Name, target: RenditionTarget<Scope>, density?: number): string;
108
+ /**
109
+ * `srcset` attribute value listing every density of a preset.
110
+ *
111
+ * @throws {RangeError} When the preset is unknown, the scope is not one the catalog declares, or the scope or path is unsafe to put into the URL.
112
+ */
113
+ srcset(preset: Name, target: RenditionTarget<Scope>): string;
114
+ }
115
+ /** Preset names of a catalog, such as `PresetName<typeof catalog>`. */
116
+ type PresetName<C extends Pick<Catalog, "presets">> = C["presets"][number];
117
+ /** Scopes a catalog declares, or `string` when it declares none. */
118
+ type ScopeName<C extends Pick<Catalog, "scopes">> = NonNullable<C["scopes"]>[number];
119
+ /**
120
+ * Validates a catalog and computes the recipe and hash of every preset density.
121
+ *
122
+ * Preset names are only labels: renaming a preset keeps its hashes and thus its stored renditions.
123
+ *
124
+ * @throws {RangeError} When the catalog is invalid.
125
+ */
126
+ declare function defineCatalog<const Name extends string, const Scope extends string = string>(definition: CatalogDefinition<Name, Scope> & (string extends Scope ? unknown : {
127
+ scopes: readonly Scope[];
128
+ })): Catalog<Name, Scope>;
129
+ //#endregion
130
+ export { RenditionTarget as a, defineCatalog as c, PresetName as i, CatalogDefinition as n, RenditionVariant as o, PresetDefinition as r, ScopeName as s, Catalog as t };
package/dist/fs.d.mts CHANGED
@@ -1,10 +1,10 @@
1
- import { n as ResultStore, r as SourceStore } from "./stores-DFDRMZcC.mjs";
1
+ import { n as ResultStore, r as SourceStore } from "./stores-P-TUmax4.mjs";
2
2
  //#region src/stores/fs.d.ts
3
3
  /** Options of {@link createFileSystemSource}. */
4
4
  export interface FileSystemSourceOptions {
5
5
  /** Directory source keys are resolved in. */
6
6
  root: string;
7
- /** Largest source file, in bytes; larger sources are unsupported. */
7
+ /** Largest source file, in bytes; larger sources fail as too large. */
8
8
  maxBytes?: number;
9
9
  }
10
10
  /** Options of {@link createFileSystemResults}. */
package/dist/fs.mjs CHANGED
@@ -10,7 +10,7 @@ function createFileSystemSource({ root, maxBytes = 52428800 }) {
10
10
  const path = resolvePath(root, key);
11
11
  const stats = await stat(path).catch(ignoreMissing);
12
12
  if (!stats?.isFile()) return;
13
- if (stats.size > maxBytes) throw new RenditionError("unsupported-source", `The source ${key} exceeds ${maxBytes} bytes.`);
13
+ if (stats.size > maxBytes) throw new RenditionError("source-too-large", `The source ${key} exceeds ${maxBytes} bytes.`);
14
14
  return new Uint8Array(await readFile(path, { signal }));
15
15
  } };
16
16
  }
package/dist/h3.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- import { r as RenditionHandlerOptions } from "./handler-BFV_vIr4.mjs";
1
+ import { r as RenditionHandlerOptions } from "./handler-RAQFWtrg.mjs";
2
2
  import { EventHandler, H3Event } from "h3";
3
3
  //#region src/h3.d.ts
4
4
  /** Source of the application context of a request. */
@@ -16,12 +16,12 @@ interface ContextOptions<Context> {
16
16
  *
17
17
  * `getContext` may be omitted when the server accepts an `undefined` context.
18
18
  */
19
- export type RenditionEventHandlerOptions<Context> = RenditionHandlerOptions<Context> & (undefined extends Context ? Partial<ContextOptions<Context>> : ContextOptions<Context>);
19
+ export type RenditionEventHandlerOptions<Context, Scope extends string = string> = RenditionHandlerOptions<Context, Scope> & (undefined extends Context ? Partial<ContextOptions<Context>> : ContextOptions<Context>);
20
20
  /**
21
21
  * Defines an h3 event handler serving rendition URLs, for h3 v1 and v2.
22
22
  *
23
23
  * Mount it at the catalog base path with a catch-all route, such as `/_renditions/**`.
24
24
  */
25
- export declare function defineRenditionEventHandler<Context>(options: RenditionEventHandlerOptions<Context>): EventHandler;
25
+ export declare function defineRenditionEventHandler<Context, Scope extends string = string>(options: RenditionEventHandlerOptions<Context, Scope>): EventHandler;
26
26
  //#endregion
27
27
  export {};
package/dist/h3.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { t as createRenditionHandler } from "./handler-BHMwNZnI.mjs";
1
+ import { t as createRenditionHandler } from "./handler-D5toFJ55.mjs";
2
2
  import { defineEventHandler } from "h3";
3
3
  //#region src/h3.ts
4
4
  /**