image-renditions 0.0.0-stage → 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ilya Semenov
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,444 @@
1
- # Temporary Holding Version
1
+ # image-renditions
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Resize and re-encode uploaded images when a page first shows them, store each result in your S3 bucket or a directory, and serve it from there.
4
+
5
+ ```vue
6
+ <RenditionImg preset="avatar" src="avatars/42.jpg" alt="" />
7
+ ```
8
+
9
+ ```html
10
+ <img
11
+ src="/_renditions/v1/avatar.faf7e221f65dd97945e352a57e805252.avif/avatars/42.jpg"
12
+ srcset="
13
+ /_renditions/v1/avatar.faf7e221f65dd97945e352a57e805252.avif/avatars/42.jpg 1x,
14
+ /_renditions/v1/avatar@2x.b62b90cf6c3ed57e4c1c27da0c36d778.avif/avatars/42.jpg 2x
15
+ "
16
+ alt=""
17
+ />
18
+ ```
19
+
20
+ - The first request for such a URL renders a 48×48 AVIF from `avatars/42.jpg`, writes it to storage, and redirects the browser to the stored file.
21
+ - Later requests redirect at once, and your bucket or CDN serves the image.
22
+ - Changing the `avatar` preset gives it new URLs, so no browser or CDN keeps showing the old version.
23
+
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
+
26
+ The h3 handler supports h3 1.15 or newer and h3 2, and the Nuxt module requires Nuxt 4.
27
+
28
+ ## When not to use it
29
+
30
+ - The URL must choose the size, such as `?w=300`.
31
+ Here every size is a preset in your code.
32
+ - Image processing must not share CPU with your app.
33
+ Here images are processed inside the app process; a separate image service, such as imgproxy, or a hosted one, such as Cloudflare Images, takes that load elsewhere.
34
+
35
+ ## Quick start
36
+
37
+ This example sets up image-renditions in a Nuxt app.
38
+
39
+ ```sh
40
+ npm install image-renditions sharp
41
+ ```
42
+
43
+ `sharp` is needed by the built-in [processor](#processing).
44
+
45
+ Define presets in a catalog.
46
+ Both the app and the server import it, so it must import nothing but `image-renditions`:
47
+
48
+ ```ts
49
+ // shared/renditions.ts
50
+ import { defineCatalog } from "image-renditions"
51
+
52
+ export default defineCatalog({
53
+ presets: {
54
+ // 48×48 for regular screens and 96×96 for high-density ones.
55
+ avatar: { width: 48, height: 48, densities: [1, 2] },
56
+ cover: { width: 800, height: 200 },
57
+ },
58
+ })
59
+ ```
60
+
61
+ Create a handler for rendition URLs, for example one that reads uploads from the `uploads` bucket and writes renditions to the public `renditions` bucket:
62
+
63
+ ```ts
64
+ // server/renditions.ts
65
+ import { S3 } from "@aws-sdk/client-s3"
66
+ import { defineRenditionEventHandler } from "image-renditions/h3"
67
+ import { createS3Results, createS3Source } from "image-renditions/s3"
68
+ import { createRenditionServer } from "image-renditions/server"
69
+ import { createSharpProcessor } from "image-renditions/sharp"
70
+
71
+ import catalog from "#shared/renditions"
72
+
73
+ const client = new S3({ region: "us-east-1" })
74
+
75
+ const server = createRenditionServer({
76
+ source: createS3Source({ client, bucket: "uploads" }),
77
+ results: createS3Results({
78
+ client,
79
+ bucket: "renditions",
80
+ publicUrl: "https://renditions.example.com",
81
+ }),
82
+ processor: createSharpProcessor(),
83
+ })
84
+
85
+ export default defineRenditionEventHandler({ catalog, server })
86
+ ```
87
+
88
+ A rendition of `avatars/42.jpg` is stored in `renditions` at `avatars/42.jpg/<hash>.avif`, and `publicUrl` is where browsers load it from.
89
+
90
+ Register the catalog and the handler:
91
+
92
+ ```ts
93
+ // nuxt.config.ts
94
+ export default defineNuxtConfig({
95
+ modules: ["image-renditions/nuxt"],
96
+ renditions: {
97
+ catalog: "#shared/renditions",
98
+ handler: "~~/server/renditions",
99
+ },
100
+ })
101
+ ```
102
+
103
+ Show images:
104
+
105
+ ```vue
106
+ <!-- app/components/UserAvatar.vue -->
107
+ <template>
108
+ <RenditionImg preset="avatar" :src="user.avatarPath" alt="" />
109
+ </template>
110
+ ```
111
+
112
+ `user.avatarPath` is the key of the upload in its bucket, such as `avatars/42.jpg`.
113
+ Preset names are type-checked against the catalog.
114
+
115
+ ## Presets
116
+
117
+ Options of a preset, or of `defaults`, which every preset overrides field by field:
118
+
119
+ - `width`, `height` — size in CSS pixels at density 1; with one side set, the other keeps the aspect ratio.
120
+ - `fit` — how the image fills the box when both sides are set:
121
+ - `cover` (default) crops what overflows the box.
122
+ - `contain` letterboxes the image inside the box.
123
+ - `fill` stretches the image to the box.
124
+ - `inside` and `outside` keep the aspect ratio, so one side of the output is smaller or larger than the box.
125
+ - `position` — `center` (default), `top`, `bottom`, `left`, `right`, or content-aware `entropy` and `attention` for `cover`.
126
+ - `enlarge` — upscale sources smaller than the target; otherwise they keep their size.
127
+ - `background` — hex color for `contain` letterboxing, transparent by default, and for flattening transparency into JPEG, white by default.
128
+ - `format` — `avif` (default), `webp`, `jpeg` or `png`.
129
+ - `quality` — encoder quality from 1 to 100, by default 50 for `avif` and 80 for `webp` and `jpeg`; `png` is lossless.
130
+ - `effort` — CPU effort of the `avif` (0–9) and `webp` (0–6) encoders, 4 by default; other formats ignore it.
131
+ - `densities` — pixel densities to render, `[1]` by default.
132
+
133
+ Options of the catalog:
134
+
135
+ - `basePath` — path the handler is mounted at, `/_renditions` by default.
136
+ - `pipelineVersion` — bump it to render every image again, for example after switching to another processor.
137
+
138
+ Changing an option that affects the output gives the images of the preset new URLs, and they are rendered again on their first request.
139
+ Renaming a preset keeps its stored renditions.
140
+
141
+ ## Showing images
142
+
143
+ `<RenditionImg>` renders `src`, and a `srcset` when the preset has several densities.
144
+
145
+ - 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.
147
+ - 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.
149
+
150
+ `useRenditions()` builds the same URLs in code, prefixed with `app.baseURL`:
151
+
152
+ ```ts
153
+ const renditions = useRenditions()
154
+
155
+ renditions.url("cover", post.coverPath)
156
+ renditions.url("avatar", path, { density: 2 })
157
+ renditions.srcset("avatar", path)
158
+ renditions.attrs("avatar", path) // attributes <RenditionImg> renders, for your own img element
159
+ ```
160
+
161
+ Module options, under `renditions`:
162
+
163
+ - `catalog` — module exporting the catalog by default; required.
164
+ - `handler` — optional module exporting the h3 handler by default, mounted at the catalog base path.
165
+ - `defaultScope` — optional [scope](#scopes) for `<RenditionImg>` and `useRenditions()`.
166
+
167
+ > 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
+
169
+ ## Scopes
170
+
171
+ The path an app stores for an upload, such as `avatars/42.jpg`, is often not its storage key.
172
+ Each tenant may keep its uploads under its own prefix, and some files may be shared by all tenants, so the same path can mean different files.
173
+
174
+ Scopes and `resolveSource` let the server decide where a path lives:
175
+
176
+ - The scope tells which space a path belongs to, such as `tenant` or `shared`.
177
+ One page may show images of several spaces, so each URL carries its scope.
178
+ - The context holds what the request determines, such as the current tenant; `getContext` derives it from the request, so a client cannot change it by editing a URL.
179
+ - `resolveSource` maps the scope, the path and the context to storage keys, or rejects them.
180
+
181
+ Without `resolveSource`, an unscoped path is the key itself, and scoped paths are rejected.
182
+
183
+ For example, with uploads of several tenants and files shared between them:
184
+
185
+ ```ts
186
+ // server/renditions.ts
187
+ const server = createRenditionServer<{ tenant: string }>({
188
+ // 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
197
+ },
198
+ })
199
+
200
+ export default defineRenditionEventHandler({
201
+ catalog,
202
+ server,
203
+ // findTenant() is your lookup, for example by host.
204
+ getContext: async event => ({ tenant: await findTenant(event) }),
205
+ })
206
+ ```
207
+
208
+ - If `resolveSource` returns `undefined`, the request fails with `404`.
209
+ - `resolveSource` receives unscoped paths too, with an undefined `scope`.
210
+ - Map different scopes to different keys even for equal paths.
211
+ - A rendition is stored in the result store at `<sourceKey>/<hash>.<extension>`.
212
+ 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.
214
+ If source keys can come that close to the limit, return a shorter `resultPrefix`, or their renditions fail as `invalid-request`.
215
+
216
+ 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
+ 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
+
219
+ With the Nuxt module, `defaultScope: "tenant"` makes `tenant` the scope of every image, and `scope` selects another one:
220
+
221
+ ```vue
222
+ <RenditionImg preset="cover" :src="template.coverPath" scope="shared" alt="" />
223
+ ```
224
+
225
+ ## Storage
226
+
227
+ 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.
229
+
230
+ ### S3 storage
231
+
232
+ To keep uploads and renditions in S3 buckets, use the stores of `image-renditions/s3`:
233
+
234
+ ```ts
235
+ // server/renditions.ts
236
+ import { S3 } from "@aws-sdk/client-s3"
237
+ import { createS3Results, createS3Source } from "image-renditions/s3"
238
+
239
+ const client = new S3({ region: "us-east-1" })
240
+
241
+ const server = createRenditionServer({
242
+ source: createS3Source({ client, bucket: "uploads" }),
243
+ results: createS3Results({
244
+ client,
245
+ bucket: "renditions",
246
+ publicUrl: "https://renditions.example.com",
247
+ }),
248
+ processor: createSharpProcessor(),
249
+ })
250
+ ```
251
+
252
+ The stores take the aggregated `S3` client of AWS SDK v3 or any object with its `getObject`, `headObject` and `putObject` methods.
253
+ `createS3Results()` writes renditions with `Cache-Control: public, max-age=31536000, immutable`; pass it `cacheControl` to change the header.
254
+ The bucket of renditions must serve them publicly at `publicUrl`, directly or through a CDN.
255
+
256
+ ### File storage
257
+
258
+ To keep uploads and renditions on a disk served by a web server, use the stores of `image-renditions/fs`:
259
+
260
+ ```ts
261
+ // server/renditions.ts
262
+ import { createFileSystemResults, createFileSystemSource } from "image-renditions/fs"
263
+
264
+ const server = createRenditionServer({
265
+ source: createFileSystemSource({ root: "/data/uploads" }),
266
+ results: createFileSystemResults({ root: "/data/renditions", publicUrl: "/renditions" }),
267
+ processor: createSharpProcessor(),
268
+ })
269
+ ```
270
+
271
+ ```nginx
272
+ location /renditions/ {
273
+ alias /data/renditions/;
274
+ add_header Cache-Control "public, max-age=31536000, immutable";
275
+ }
276
+ ```
277
+
278
+ ## Processing
279
+
280
+ `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.
282
+ 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
+ Under `cover`, such a source is cropped instead, and its rendition keeps the preset size.
284
+
285
+ To process images with another library, implement the [`Processor`](#extending) interface and pass your implementation to `createRenditionServer()` as `processor`.
286
+
287
+ ## h3 and other frameworks
288
+
289
+ The handler from the quick start is a regular h3 event handler.
290
+ Mount it at `<basePath>/**`, `/_renditions/**` by default:
291
+
292
+ ```ts
293
+ // server.ts
294
+ import { H3, serve } from "h3"
295
+
296
+ import renditions from "./renditions"
297
+
298
+ serve(new H3().all("/_renditions/**", renditions))
299
+ ```
300
+
301
+ With h3 v1, add it to a router: `createRouter().use("/_renditions/**", renditions)`.
302
+
303
+ For other frameworks, use `createRenditionHandler()` from `image-renditions/server`.
304
+ It takes the same options without `getContext` and returns a function from the request and a function returning the context to a `Response`, for example in a fetch handler:
305
+
306
+ ```ts
307
+ // server.ts
308
+ import { createRenditionHandler } from "image-renditions/server"
309
+
310
+ const renditions = createRenditionHandler({ catalog, server })
311
+
312
+ export default {
313
+ fetch(request: Request) {
314
+ const { pathname, search } = new URL(request.url)
315
+ // The second argument returns the context resolveSource receives, such as the current tenant.
316
+ return renditions({ method: request.method, path: pathname + search }, () => undefined)
317
+ },
318
+ }
319
+ ```
320
+
321
+ Build URLs in the app with the catalog, which is safe to import in browsers:
322
+
323
+ ```ts
324
+ catalog.url("avatar", { path: "avatars/42.jpg" })
325
+ // /_renditions/v1/avatar.faf7e221f65dd97945e352a57e805252.avif/avatars/42.jpg
326
+
327
+ catalog.srcset("avatar", { scope: "tenant", path: "avatars/42.jpg" })
328
+ ```
329
+
330
+ ## Rendering outside requests
331
+
332
+ `server.ensure()` publishes one density of a preset without a request, for example right after an upload:
333
+
334
+ ```ts
335
+ for (const { recipe } of catalog.variants("avatar")) {
336
+ await server.ensure(recipe, { scope: "tenant", path: "avatars/42.jpg" }, { tenant: "acme" })
337
+ }
338
+ ```
339
+
340
+ It resolves to the key and public URL of the rendition after it is written or found in storage.
341
+
342
+ ## How a request is served
343
+
344
+ ```text
345
+ /_renditions/v1/[<scope>/]<preset>.<hash>.<extension>/<path>
346
+ ```
347
+
348
+ The hash identifies the preset options at one density, and the preset name only labels the URL.
349
+ The handler looks up the hash in the catalog, resolves the source, and then:
350
+
351
+ 1. If the process or a shared readiness index remembers the rendition, it redirects right away.
352
+ 2. Otherwise it checks storage and redirects if the rendition is there.
353
+ 3. Otherwise it reads the source, renders it, writes the result, and redirects.
354
+
355
+ Within a process, equal concurrent requests are rendered once.
356
+ Replicas without a shared `coordinator` may each render the same rendition and write equal files under its key.
357
+
358
+ The redirect is a `307` with `Cache-Control: public, max-age=86400`, so browsers and CDNs remember it for a day.
359
+ It points to the storage URL built from `publicUrl`, and a cached redirect cannot be revoked: after you move renditions to another domain, clients follow the old redirects until they expire.
360
+ If `publicUrl` never changes, pass a longer `redirectCacheControl` to `defineRenditionEventHandler()`.
361
+
362
+ ## Concurrency and timeouts
363
+
364
+ Rendering takes the CPU and memory of the app process, so the `limits` option of `createRenditionServer()` bounds it per process:
365
+
366
+ - `concurrency` — renditions processed at once, 2 by default.
367
+ - `queueSize` — renditions waiting for a slot, 100 by default.
368
+ - `queueTimeout` — milliseconds a rendition may wait for a slot, 30 seconds by default.
369
+ - `storageTimeout` — milliseconds a storage call may take, 15 seconds by default.
370
+ - `indexTimeout` — milliseconds a shared readiness index call may take, 1 second by default.
371
+
372
+ Requests beyond the queue, or waiting too long, fail as `overloaded`.
373
+ Processing itself has no timeout, because a timeout could not stop the processor and free its slot.
374
+
375
+ These limits bound image processing, not HTTP request rates.
376
+ Requests for renditions missing from the readiness indexes check result storage, even when the source does not exist.
377
+ On a public site, rate-limit requests per client at the CDN or reverse proxy.
378
+
379
+ ## Errors
380
+
381
+ The handler responds to a failure with the status of its reason:
382
+
383
+ - `invalid-request` (404): malformed URL, `resolveSource` rejected the target, or a key is unsafe or too long for the store
384
+ - `unknown-recipe` (404): hash neither in the catalog nor known as published
385
+ - `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
387
+ - `source-error` (502): reading the source failed
388
+ - `processing-failed` (500): processing failed for a reason other than the source
389
+ - `storage-error` (502): checking for or writing the rendition failed
390
+ - `overloaded` (503): processing queue is full or the wait timed out
391
+
392
+ ### Outdated URLs
393
+
394
+ A page rendered before a preset changed keeps requesting the old hash, which the catalog no longer has.
395
+ The handler serves it while the process or a shared readiness index remembers its rendition.
396
+ Otherwise the request fails as `unknown-recipe` without calling the stores, so made-up hashes cause no storage traffic either.
397
+
398
+ ### Custom responses
399
+
400
+ `onFailure` can report a failure or replace the response, for example with the original image:
401
+
402
+ ```ts
403
+ export default defineRenditionEventHandler({
404
+ catalog,
405
+ server,
406
+ getContext,
407
+ onFailure({ reason, error, address, context }) {
408
+ if (reason === "storage-error" || reason === "processing-failed") {
409
+ reportError(error)
410
+ }
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)
414
+ }
415
+ return undefined
416
+ },
417
+ })
418
+ ```
419
+
420
+ Returning `undefined` keeps the default response.
421
+ Every failure response, including a replaced one, is sent with `Cache-Control: no-store`.
422
+
423
+ ## Extending
424
+
425
+ Implement an interface from `image-renditions/server` and pass your implementation to `createRenditionServer()`:
426
+
427
+ - To use other storage, implement `SourceStore` or `ResultStore` and pass it as `source` or `results`.
428
+ - To process images with another library, implement `Processor` and pass it as `processor`.
429
+ - To share the index of published renditions between replicas, for example in Redis, implement `ReadinessIndex` and pass it as `readiness.shared`.
430
+ 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
+ - To render each rendition once across replicas, implement `Coordinator` as a shared lock and pass it as `coordinator`.
432
+ Bound the wait for the lock, because the server sets no timeout on it.
433
+
434
+ The built-in stores and processor implement the same interfaces and serve as examples.
435
+
436
+ ## Limitations
437
+
438
+ - Renditions are public, and anyone who knows a path that `resolveSource` serves can get its renditions.
439
+ Keep private images out of what `resolveSource` maps, for example by serving only the folders of public images.
440
+ - A source must never change under its key: renditions of a replaced source keep showing the old image.
441
+ 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.
444
+ - Source paths are limited to 1024 characters.
@@ -0,0 +1,64 @@
1
+ import { l as SourcePolicy, r as Recipe, s as RenditionOptions } from "./recipe-Je9DxqO1.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
+ /** Shared configuration of renditions, imported by both the client and the server. */
9
+ interface CatalogDefinition<Name extends string = string> {
10
+ /** Path the rendition handler is mounted at, `/_renditions` by default. */
11
+ basePath?: string;
12
+ /** Bumping it gives every variant a new hash, so every rendition is generated again. */
13
+ pipelineVersion?: number;
14
+ /** Options of every preset; a preset overrides them field by field. */
15
+ defaults?: PresetDefinition;
16
+ /** How special kinds of sources are rendered. */
17
+ sources?: Partial<SourcePolicy>;
18
+ presets: Record<Name, PresetDefinition>;
19
+ }
20
+ /** One density of a preset, which is a separate recipe with its own hash. */
21
+ interface RenditionVariant {
22
+ readonly preset: string;
23
+ readonly density: number;
24
+ /** Readable URL label, such as `avatar` or `avatar@2x`. */
25
+ readonly label: string;
26
+ readonly recipe: Recipe;
27
+ readonly hash: string;
28
+ }
29
+ /** Source a rendition is made from. */
30
+ interface RenditionTarget {
31
+ /** Application-defined space of the path, such as a tenant; omitted when the application has one space. */
32
+ scope?: string;
33
+ /** Logical source path inside the scope. */
34
+ path: string;
35
+ }
36
+ /** Validated catalog with its variants and URL builders. */
37
+ interface Catalog<Name extends string = string> {
38
+ readonly basePath: string;
39
+ readonly presets: readonly Name[];
40
+ /** Variants of a preset ordered by density. */
41
+ variants(preset: Name): readonly RenditionVariant[];
42
+ /**
43
+ * Variant of a preset at a density, `1` by default.
44
+ *
45
+ * @throws {RangeError} When the preset does not declare the density.
46
+ */
47
+ variant(preset: Name, density?: number): RenditionVariant;
48
+ /** Variant with a recipe hash, if the catalog has one. */
49
+ findVariant(hash: string): RenditionVariant | undefined;
50
+ /** Root-relative URL of a rendition. */
51
+ url(preset: Name, target: RenditionTarget, density?: number): string;
52
+ /** `srcset` attribute value listing every density of a preset. */
53
+ srcset(preset: Name, target: RenditionTarget): string;
54
+ }
55
+ /**
56
+ * Validates a catalog and computes the recipe and hash of every preset density.
57
+ *
58
+ * Preset names are only labels: renaming a preset keeps its hashes and thus its stored renditions.
59
+ *
60
+ * @throws {RangeError} When the catalog is invalid.
61
+ */
62
+ declare function defineCatalog<const Name extends string>(definition: CatalogDefinition<Name>): Catalog<Name>;
63
+ //#endregion
64
+ export { RenditionVariant as a, RenditionTarget as i, CatalogDefinition as n, defineCatalog as o, PresetDefinition as r, Catalog as t };
@@ -0,0 +1,12 @@
1
+ //#region src/server/error.ts
2
+ /** Failure of a rendition operation, with a reason a failure policy can tell apart. */
3
+ var RenditionError = class extends Error {
4
+ reason;
5
+ name = "RenditionError";
6
+ constructor(reason, message = reason, options) {
7
+ super(message, options);
8
+ this.reason = reason;
9
+ }
10
+ };
11
+ //#endregion
12
+ export { RenditionError as t };
package/dist/fs.d.mts ADDED
@@ -0,0 +1,25 @@
1
+ import { n as ResultStore, r as SourceStore } from "./stores-DFDRMZcC.mjs";
2
+ //#region src/stores/fs.d.ts
3
+ /** Options of {@link createFileSystemSource}. */
4
+ export interface FileSystemSourceOptions {
5
+ /** Directory source keys are resolved in. */
6
+ root: string;
7
+ /** Largest source file, in bytes; larger sources are unsupported. */
8
+ maxBytes?: number;
9
+ }
10
+ /** Options of {@link createFileSystemResults}. */
11
+ export interface FileSystemResultsOptions {
12
+ /** Directory renditions are written to. */
13
+ root: string;
14
+ /**
15
+ * URL the directory is served at, such as `https://cdn.example.com/renditions` or `/renditions`.
16
+ *
17
+ * The web server in front of it sets `Cache-Control`; renditions never change, so it can mark them immutable.
18
+ */
19
+ publicUrl: string;
20
+ }
21
+ /** Creates a source store reading files from a directory. */
22
+ export declare function createFileSystemSource({ root, maxBytes }: FileSystemSourceOptions): SourceStore;
23
+ /** Creates a result store writing files to a directory that a web server serves. */
24
+ export declare function createFileSystemResults({ root, publicUrl }: FileSystemResultsOptions): ResultStore;
25
+ //#endregion
package/dist/fs.mjs ADDED
@@ -0,0 +1,50 @@
1
+ import { n as isSafePath, t as encodePath } from "./path-hNFUjCbA.mjs";
2
+ import { t as RenditionError } from "./error-DViVpVmH.mjs";
3
+ import { randomUUID } from "node:crypto";
4
+ import { mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
5
+ import { dirname, resolve, sep } from "node:path";
6
+ //#region src/stores/fs.ts
7
+ /** Creates a source store reading files from a directory. */
8
+ function createFileSystemSource({ root, maxBytes = 52428800 }) {
9
+ return { async read(key, { signal }) {
10
+ const path = resolvePath(root, key);
11
+ const stats = await stat(path).catch(ignoreMissing);
12
+ if (!stats?.isFile()) return;
13
+ if (stats.size > maxBytes) throw new RenditionError("unsupported-source", `The source ${key} exceeds ${maxBytes} bytes.`);
14
+ return new Uint8Array(await readFile(path, { signal }));
15
+ } };
16
+ }
17
+ /** Creates a result store writing files to a directory that a web server serves. */
18
+ function createFileSystemResults({ root, publicUrl }) {
19
+ return {
20
+ async exists(key) {
21
+ return (await stat(resolvePath(root, key)).catch(ignoreMissing))?.isFile() ?? false;
22
+ },
23
+ async write(key, { data }, { signal }) {
24
+ const path = resolvePath(root, key);
25
+ await mkdir(dirname(path), { recursive: true });
26
+ const temporary = `${path}.${randomUUID()}.tmp`;
27
+ try {
28
+ await writeFile(temporary, data, { signal });
29
+ await rename(temporary, path);
30
+ } catch (error) {
31
+ await rm(temporary, { force: true });
32
+ throw error;
33
+ }
34
+ },
35
+ url: (key) => `${publicUrl.replace(/\/$/, "")}/${encodePath(key)}`
36
+ };
37
+ }
38
+ function resolvePath(root, key) {
39
+ const base = resolve(root);
40
+ const path = resolve(base, key);
41
+ const prefix = base.endsWith(sep) ? base : base + sep;
42
+ if (!isSafePath(key) || !path.startsWith(prefix)) throw new RenditionError("invalid-request", `Unsafe storage key ${key}.`);
43
+ return path;
44
+ }
45
+ function ignoreMissing(error) {
46
+ if (error.code === "ENOENT" || error.code === "ENOTDIR") return;
47
+ throw error;
48
+ }
49
+ //#endregion
50
+ export { createFileSystemResults, createFileSystemSource };
package/dist/h3.d.mts ADDED
@@ -0,0 +1,27 @@
1
+ import { r as RenditionHandlerOptions } from "./handler-BFV_vIr4.mjs";
2
+ import { EventHandler, H3Event } from "h3";
3
+ //#region src/h3.d.ts
4
+ /** Source of the application context of a request. */
5
+ interface ContextOptions<Context> {
6
+ /**
7
+ * Derives the application context, such as a tenant, from the request event.
8
+ *
9
+ * Caches reuse a redirect for every request of its URL, so derive the context from the URL and host only, or set `redirectCacheControl` to `no-store`.
10
+ * It is skipped for unsupported methods and for malformed URLs unless `onFailure` needs the context.
11
+ */
12
+ getContext(event: H3Event): Context | Promise<Context>;
13
+ }
14
+ /**
15
+ * Options of {@link defineRenditionEventHandler}.
16
+ *
17
+ * `getContext` may be omitted when the server accepts an `undefined` context.
18
+ */
19
+ export type RenditionEventHandlerOptions<Context> = RenditionHandlerOptions<Context> & (undefined extends Context ? Partial<ContextOptions<Context>> : ContextOptions<Context>);
20
+ /**
21
+ * Defines an h3 event handler serving rendition URLs, for h3 v1 and v2.
22
+ *
23
+ * Mount it at the catalog base path with a catch-all route, such as `/_renditions/**`.
24
+ */
25
+ export declare function defineRenditionEventHandler<Context>(options: RenditionEventHandlerOptions<Context>): EventHandler;
26
+ //#endregion
27
+ export {};
package/dist/h3.mjs ADDED
@@ -0,0 +1,25 @@
1
+ import { t as createRenditionHandler } from "./handler-BHMwNZnI.mjs";
2
+ import { defineEventHandler } from "h3";
3
+ //#region src/h3.ts
4
+ /**
5
+ * Defines an h3 event handler serving rendition URLs, for h3 v1 and v2.
6
+ *
7
+ * Mount it at the catalog base path with a catch-all route, such as `/_renditions/**`.
8
+ */
9
+ function defineRenditionEventHandler(options) {
10
+ const handler = createRenditionHandler(options);
11
+ const getContext = options.getContext;
12
+ return defineEventHandler((event) => handler(readRequest(event), () => getContext?.(event)));
13
+ }
14
+ function readRequest(event) {
15
+ if (event.url instanceof URL) return {
16
+ method: event.req.method,
17
+ path: event.url.pathname + event.url.search
18
+ };
19
+ return {
20
+ method: event.method,
21
+ path: event.path
22
+ };
23
+ }
24
+ //#endregion
25
+ export { defineRenditionEventHandler };