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 +193 -42
- package/dist/catalog-D11xkViN.d.mts +130 -0
- package/dist/fs.d.mts +2 -2
- package/dist/fs.mjs +1 -1
- package/dist/h3.d.mts +3 -3
- package/dist/h3.mjs +1 -1
- package/dist/{handler-BHMwNZnI.mjs → handler-D5toFJ55.mjs} +12 -10
- package/dist/{handler-BFV_vIr4.d.mts → handler-RAQFWtrg.d.mts} +41 -21
- package/dist/hash-CHipV1N6.mjs +16 -0
- package/dist/img-attrs-BoCAHpl5.mjs +14 -0
- package/dist/index.d.mts +4 -6
- package/dist/index.mjs +63 -19
- package/dist/nuxt/runtime.d.mts +37 -11
- package/dist/nuxt/runtime.mjs +67 -16
- package/dist/nuxt.d.mts +11 -1
- package/dist/nuxt.mjs +18 -3
- package/dist/processor-DvyPs-OZ.d.mts +22 -0
- package/dist/{recipe-Je9DxqO1.d.mts → recipe-BT8D60Qb.d.mts} +11 -5
- package/dist/{recipe-DmOhQJWE.mjs → recipe-BavgOq0b.mjs} +7 -6
- package/dist/s3.d.mts +8 -34
- package/dist/s3.mjs +8 -7
- package/dist/scopes-C1kzqQHz.mjs +22 -0
- package/dist/server.d.mts +3 -3
- package/dist/server.mjs +37 -14
- package/dist/sharp.d.mts +16 -4
- package/dist/sharp.mjs +86 -24
- package/dist/{stores-DFDRMZcC.d.mts → stores-P-TUmax4.d.mts} +1 -1
- package/dist/{url-BIvAw2BR.mjs → url-C6PtYIEf.mjs} +11 -15
- package/dist/{url-BByyAbmB.d.mts → url-DoJdUx9J.d.mts} +2 -10
- package/package.json +5 -1
- package/dist/catalog-CSnosQ1L.d.mts +0 -64
- package/dist/processor-_xuwB7Aa.d.mts +0 -14
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.
|
|
11
|
+
src="/_renditions/v1/avatar.faf7e221f65d.avif/avatars/42.jpg"
|
|
12
12
|
srcset="
|
|
13
|
-
/_renditions/v1/avatar.
|
|
14
|
-
/_renditions/v1/avatar@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
|
|
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 {
|
|
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
|
|
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({
|
|
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
|
-
|
|
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
|
-
-
|
|
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,
|
|
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) //
|
|
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
|
|
221
|
+
const server = createRenditionServer({
|
|
222
|
+
catalog,
|
|
188
223
|
// source, results and processor as in the quick start
|
|
189
|
-
resolveSource({ scope, path }, { tenant }) {
|
|
190
|
-
|
|
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
|
|
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
|
|
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
|
|
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 {
|
|
350
|
+
import { S3Client } from "@aws-sdk/client-s3"
|
|
237
351
|
import { createS3Results, createS3Source } from "image-renditions/s3"
|
|
238
352
|
|
|
239
|
-
const client = new
|
|
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
|
|
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
|
|
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({
|
|
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.
|
|
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
|
-
|
|
336
|
-
|
|
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
|
|
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,
|
|
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" &&
|
|
412
|
-
//
|
|
413
|
-
|
|
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
|
-
-
|
|
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-
|
|
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
|
|
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("
|
|
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-
|
|
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