image-renditions 0.1.0 → 0.2.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 +181 -36
- 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-BFV_vIr4.d.mts → handler-9XP5SZhg.d.mts} +41 -21
- package/dist/{handler-BHMwNZnI.mjs → handler-CPJllrYi.mjs} +12 -10
- package/dist/img-attrs-BoCAHpl5.mjs +14 -0
- package/dist/index.d.mts +4 -4
- package/dist/index.mjs +57 -18
- 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-BAU5GYXC.mjs} +1 -1
- package/dist/{url-BByyAbmB.d.mts → url-BVH3ZB0F.d.mts} +1 -1
- 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
|
@@ -62,7 +62,7 @@ Create a handler for rendition URLs, for example one that reads uploads from the
|
|
|
62
62
|
|
|
63
63
|
```ts
|
|
64
64
|
// server/renditions.ts
|
|
65
|
-
import {
|
|
65
|
+
import { S3Client } from "@aws-sdk/client-s3"
|
|
66
66
|
import { defineRenditionEventHandler } from "image-renditions/h3"
|
|
67
67
|
import { createS3Results, createS3Source } from "image-renditions/s3"
|
|
68
68
|
import { createRenditionServer } from "image-renditions/server"
|
|
@@ -70,9 +70,10 @@ import { createSharpProcessor } from "image-renditions/sharp"
|
|
|
70
70
|
|
|
71
71
|
import catalog from "#shared/renditions"
|
|
72
72
|
|
|
73
|
-
const client = new
|
|
73
|
+
const client = new S3Client({ region: "us-east-1" })
|
|
74
74
|
|
|
75
75
|
const server = createRenditionServer({
|
|
76
|
+
catalog,
|
|
76
77
|
source: createS3Source({ client, bucket: "uploads" }),
|
|
77
78
|
results: createS3Results({
|
|
78
79
|
client,
|
|
@@ -82,7 +83,7 @@ const server = createRenditionServer({
|
|
|
82
83
|
processor: createSharpProcessor(),
|
|
83
84
|
})
|
|
84
85
|
|
|
85
|
-
export default defineRenditionEventHandler({
|
|
86
|
+
export default defineRenditionEventHandler({ server })
|
|
86
87
|
```
|
|
87
88
|
|
|
88
89
|
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 +130,8 @@ Options of a preset, or of `defaults`, which every preset overrides field by fie
|
|
|
129
130
|
- `quality` — encoder quality from 1 to 100, by default 50 for `avif` and 80 for `webp` and `jpeg`; `png` is lossless.
|
|
130
131
|
- `effort` — CPU effort of the `avif` (0–9) and `webp` (0–6) encoders, 4 by default; other formats ignore it.
|
|
131
132
|
- `densities` — pixel densities to render, `[1]` by default.
|
|
133
|
+
- `animation` — `first-frame` (default) renders the first frame of animated sources, and `preserve` keeps the animation of GIF and WebP sources; see [Animation](#animation).
|
|
134
|
+
- `originals` — extensions of sources shown as they are, so no option above applies to them; see [Originals](#originals).
|
|
132
135
|
|
|
133
136
|
Options of the catalog:
|
|
134
137
|
|
|
@@ -143,11 +146,13 @@ Renaming a preset keeps its stored renditions.
|
|
|
143
146
|
`<RenditionImg>` renders `src`, and a `srcset` when the preset has several densities.
|
|
144
147
|
|
|
145
148
|
- 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
|
-
|
|
149
|
+
It renders neither for other presets, including ones that set a single side, so size their images with CSS.
|
|
147
150
|
- A `src` that is already a URL, such as `blob:` or `https:`, renders unchanged, for example a preview of a file being uploaded.
|
|
148
|
-
-
|
|
151
|
+
- An unsafe path, such as one with a `..` segment, renders no `src` instead of breaking the page.
|
|
152
|
+
- A source whose extension the preset lists in [`originals`](#originals) renders as it is.
|
|
153
|
+
- A GIF or WebP source of a preset with [`animation: "preserve"`](#animation) renders its animated variants.
|
|
149
154
|
|
|
150
|
-
`useRenditions()` builds the same URLs in code,
|
|
155
|
+
`useRenditions()` builds the same URLs in code, prefixing rendition URLs with `app.baseURL`:
|
|
151
156
|
|
|
152
157
|
```ts
|
|
153
158
|
const renditions = useRenditions()
|
|
@@ -155,14 +160,19 @@ const renditions = useRenditions()
|
|
|
155
160
|
renditions.url("cover", post.coverPath)
|
|
156
161
|
renditions.url("avatar", path, { density: 2 })
|
|
157
162
|
renditions.srcset("avatar", path)
|
|
158
|
-
renditions.attrs("avatar", path) //
|
|
163
|
+
renditions.attrs("avatar", path) // src, srcset and size of <RenditionImg>, for your own img element
|
|
159
164
|
```
|
|
160
165
|
|
|
166
|
+
`url()` and `srcset()` return `undefined` for an unsafe path.
|
|
167
|
+
|
|
161
168
|
Module options, under `renditions`:
|
|
162
169
|
|
|
163
170
|
- `catalog` — module exporting the catalog by default; required.
|
|
164
171
|
- `handler` — optional module exporting the h3 handler by default, mounted at the catalog base path.
|
|
165
172
|
- `defaultScope` — optional [scope](#scopes) for `<RenditionImg>` and `useRenditions()`.
|
|
173
|
+
- `originalUrl` — module building URLs of [originals](#originals); required when a preset lists `originals`.
|
|
174
|
+
- `imgAttrs` — attributes every `<RenditionImg>` renders, such as `{ loading: "lazy", decoding: "async" }`.
|
|
175
|
+
An attribute of the element overrides them, for example `loading="eager"` on an image above the fold.
|
|
166
176
|
|
|
167
177
|
> 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
178
|
|
|
@@ -180,25 +190,37 @@ Scopes and `resolveSource` let the server decide where a path lives:
|
|
|
180
190
|
|
|
181
191
|
Without `resolveSource`, an unscoped path is the key itself, and scoped paths are rejected.
|
|
182
192
|
|
|
183
|
-
For example, with uploads of several tenants and files shared between them:
|
|
193
|
+
For example, with uploads of several tenants and files shared between them, declare both scopes in the catalog:
|
|
194
|
+
|
|
195
|
+
```ts
|
|
196
|
+
// shared/renditions.ts
|
|
197
|
+
export default defineCatalog({
|
|
198
|
+
scopes: ["tenant", "shared"],
|
|
199
|
+
presets: {
|
|
200
|
+
// ...
|
|
201
|
+
},
|
|
202
|
+
})
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
- Scope names are type-checked against the catalog, like preset names.
|
|
206
|
+
- `catalog.url()` requires a scope.
|
|
207
|
+
`useRenditions()` and `<RenditionImg>` use the module's `defaultScope` when a call has no scope, and throw a `RangeError` when there is neither.
|
|
208
|
+
- The server rejects a target with another scope or no scope as `invalid-request`, so `resolveSource` receives only the declared scopes.
|
|
209
|
+
- `ScopeName<typeof catalog>` is the union of the scopes, and `PresetName<typeof catalog>` is the union of the preset names.
|
|
210
|
+
|
|
211
|
+
The catalog of the server types the scopes `resolveSource` receives, so it handles each of them:
|
|
184
212
|
|
|
185
213
|
```ts
|
|
186
214
|
// server/renditions.ts
|
|
187
|
-
const server = createRenditionServer
|
|
215
|
+
const server = createRenditionServer({
|
|
216
|
+
catalog,
|
|
188
217
|
// 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
|
|
218
|
+
resolveSource({ scope, path }, { tenant }: { tenant: string }) {
|
|
219
|
+
return { sourceKey: scope === "tenant" ? `tenants/${tenant}/${path}` : `shared/${path}` }
|
|
197
220
|
},
|
|
198
221
|
})
|
|
199
222
|
|
|
200
223
|
export default defineRenditionEventHandler({
|
|
201
|
-
catalog,
|
|
202
224
|
server,
|
|
203
225
|
// findTenant() is your lookup, for example by host.
|
|
204
226
|
getContext: async event => ({ tenant: await findTenant(event) }),
|
|
@@ -206,7 +228,7 @@ export default defineRenditionEventHandler({
|
|
|
206
228
|
```
|
|
207
229
|
|
|
208
230
|
- If `resolveSource` returns `undefined`, the request fails with `404`.
|
|
209
|
-
- `resolveSource` receives
|
|
231
|
+
- Without scopes in the catalog, `resolveSource` receives any scope a URL has, or none, and rejects the ones it does not serve.
|
|
210
232
|
- Map different scopes to different keys even for equal paths.
|
|
211
233
|
- A rendition is stored in the result store at `<sourceKey>/<hash>.<extension>`.
|
|
212
234
|
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`.
|
|
@@ -216,16 +238,102 @@ export default defineRenditionEventHandler({
|
|
|
216
238
|
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
239
|
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
240
|
|
|
219
|
-
With the Nuxt module, `defaultScope: "tenant"` makes `tenant` the scope of every image, and `scope` selects another one:
|
|
241
|
+
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
242
|
|
|
221
243
|
```vue
|
|
222
244
|
<RenditionImg preset="cover" :src="template.coverPath" scope="shared" alt="" />
|
|
223
245
|
```
|
|
224
246
|
|
|
247
|
+
## Originals
|
|
248
|
+
|
|
249
|
+
Some sources are better shown as they are: SVG stays crisp at any zoom, and [`createSharpProcessor()`](#processing) cannot decode ICO or BMP.
|
|
250
|
+
`originals` lists the extensions of such sources, in lowercase and without the dot:
|
|
251
|
+
|
|
252
|
+
```ts
|
|
253
|
+
export default defineCatalog({
|
|
254
|
+
defaults: { originals: ["svg", "ico", "bmp"] },
|
|
255
|
+
presets: {
|
|
256
|
+
logo: { height: 32 },
|
|
257
|
+
// Email clients do not show SVG.
|
|
258
|
+
"email-logo": { height: 32, originals: [] },
|
|
259
|
+
},
|
|
260
|
+
})
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
For such a path, `<RenditionImg>` and `useRenditions()` give the URL of the source itself:
|
|
264
|
+
|
|
265
|
+
- `url()` returns it for every declared density, and `srcset()` returns `undefined`.
|
|
266
|
+
- `<RenditionImg>` renders no `srcset`, `width` or `height`, so size such images with CSS.
|
|
267
|
+
- `gif` in the list shows every GIF as it is, because a path does not tell an animated GIF from a still one.
|
|
268
|
+
Leave it out and set [`animation: "preserve"`](#animation) instead to shrink animated GIFs.
|
|
269
|
+
- Only the extension is checked, not the content, and the server still renders the source when its rendition URL is requested.
|
|
270
|
+
- Changing `originals` keeps the hashes of the preset.
|
|
271
|
+
|
|
272
|
+
Only the app knows where its sources are served from, so it builds their URLs.
|
|
273
|
+
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:
|
|
274
|
+
|
|
275
|
+
```ts
|
|
276
|
+
// app/renditions/original-url.ts
|
|
277
|
+
import { encodePath, type RenditionTarget } from "image-renditions"
|
|
278
|
+
|
|
279
|
+
export default function useOriginalUrl() {
|
|
280
|
+
const { uploadsUrl } = useRuntimeConfig().public
|
|
281
|
+
return ({ path }: RenditionTarget) => `${uploadsUrl}/${encodePath(path)}`
|
|
282
|
+
}
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
```ts
|
|
286
|
+
// nuxt.config.ts
|
|
287
|
+
export default defineNuxtConfig({
|
|
288
|
+
renditions: {
|
|
289
|
+
catalog: "#shared/renditions",
|
|
290
|
+
originalUrl: "~/renditions/original-url",
|
|
291
|
+
},
|
|
292
|
+
})
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
- `useRenditions()` calls the composable every time it is called, so the composable may use other composables.
|
|
296
|
+
- 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.
|
|
297
|
+
- The URL it returns is used as is, without `app.baseURL`.
|
|
298
|
+
- The build fails when a preset lists `originals` and `originalUrl` is not set.
|
|
299
|
+
|
|
300
|
+
> `<img>` never runs scripts of an SVG, but an SVG opened as a page runs them on the site it is served from.
|
|
301
|
+
> Keep SVG files that users upload from running scripts as part of the app in any of these ways:
|
|
302
|
+
>
|
|
303
|
+
> - Serve them from a separate domain rather than a subdomain of the app.
|
|
304
|
+
> - Serve them with a `Content-Security-Policy: sandbox` response header.
|
|
305
|
+
> - Store them with `Content-Disposition: attachment`, which S3 keeps with the object, so browsers download such a file instead of opening it.
|
|
306
|
+
|
|
307
|
+
## Animation
|
|
308
|
+
|
|
309
|
+
A rendition of an animated source shows its first frame.
|
|
310
|
+
`animation: "preserve"` keeps the animation of GIF and WebP sources instead:
|
|
311
|
+
|
|
312
|
+
```ts
|
|
313
|
+
export default defineCatalog({
|
|
314
|
+
presets: {
|
|
315
|
+
thumb: { width: 96, height: 96 },
|
|
316
|
+
content: { width: 800, densities: [1, 2], animation: "preserve" },
|
|
317
|
+
},
|
|
318
|
+
})
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
- 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.
|
|
322
|
+
- 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.
|
|
323
|
+
- A still GIF or WebP source renders as a still WebP image.
|
|
324
|
+
- Only the extension is checked, and the server renders any source with these variants when their URL is requested.
|
|
325
|
+
- The variants of other sources keep their hashes, so only GIF and WebP sources render again.
|
|
326
|
+
- [`originals`](#originals) take precedence, so a preset that lists `gif` in them shows GIF sources as they are.
|
|
327
|
+
|
|
328
|
+
To render the animated variants ahead of a request, pass the source path to `catalog.variants()`, as in [Rendering outside requests](#rendering-outside-requests).
|
|
329
|
+
|
|
330
|
+
How much of an animation a rendition keeps depends on the processor; see [Processing](#processing) for `createSharpProcessor()`.
|
|
331
|
+
`createRenditionServer()` fails when its processor cannot render a variant of the catalog.
|
|
332
|
+
|
|
225
333
|
## Storage
|
|
226
334
|
|
|
227
335
|
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
|
|
336
|
+
Both source stores fail sources larger than 50 MiB as `source-too-large`; pass `maxBytes` to `createS3Source()` or `createFileSystemSource()` to change the limit.
|
|
229
337
|
|
|
230
338
|
### S3 storage
|
|
231
339
|
|
|
@@ -233,12 +341,13 @@ To keep uploads and renditions in S3 buckets, use the stores of `image-rendition
|
|
|
233
341
|
|
|
234
342
|
```ts
|
|
235
343
|
// server/renditions.ts
|
|
236
|
-
import {
|
|
344
|
+
import { S3Client } from "@aws-sdk/client-s3"
|
|
237
345
|
import { createS3Results, createS3Source } from "image-renditions/s3"
|
|
238
346
|
|
|
239
|
-
const client = new
|
|
347
|
+
const client = new S3Client({ region: "us-east-1" })
|
|
240
348
|
|
|
241
349
|
const server = createRenditionServer({
|
|
350
|
+
catalog,
|
|
242
351
|
source: createS3Source({ client, bucket: "uploads" }),
|
|
243
352
|
results: createS3Results({
|
|
244
353
|
client,
|
|
@@ -249,7 +358,7 @@ const server = createRenditionServer({
|
|
|
249
358
|
})
|
|
250
359
|
```
|
|
251
360
|
|
|
252
|
-
The stores take
|
|
361
|
+
The stores take a client of AWS SDK v3, the bare `S3Client` or the aggregated `S3`.
|
|
253
362
|
`createS3Results()` writes renditions with `Cache-Control: public, max-age=31536000, immutable`; pass it `cacheControl` to change the header.
|
|
254
363
|
The bucket of renditions must serve them publicly at `publicUrl`, directly or through a CDN.
|
|
255
364
|
|
|
@@ -262,6 +371,7 @@ To keep uploads and renditions on a disk served by a web server, use the stores
|
|
|
262
371
|
import { createFileSystemResults, createFileSystemSource } from "image-renditions/fs"
|
|
263
372
|
|
|
264
373
|
const server = createRenditionServer({
|
|
374
|
+
catalog,
|
|
265
375
|
source: createFileSystemSource({ root: "/data/uploads" }),
|
|
266
376
|
results: createFileSystemResults({ root: "/data/renditions", publicUrl: "/renditions" }),
|
|
267
377
|
processor: createSharpProcessor(),
|
|
@@ -278,10 +388,21 @@ location /renditions/ {
|
|
|
278
388
|
## Processing
|
|
279
389
|
|
|
280
390
|
`createSharpProcessor()` from `image-renditions/sharp` processes images with [sharp](https://sharp.pixelplumbing.com).
|
|
281
|
-
Sources over 100 megapixels fail as
|
|
391
|
+
Sources over 100 megapixels fail as `source-too-large`; pass `maxInputPixels` to `createSharpProcessor()` to change the limit, or `0` to remove it.
|
|
282
392
|
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
393
|
Under `cover`, such a source is cropped instead, and its rendition keeps the preset size.
|
|
284
394
|
|
|
395
|
+
`createSharpProcessor()` keeps the [animation](#animation) of GIF and WebP sources.
|
|
396
|
+
It does not crop animations by `entropy` or `attention`, so a server fails to start with a preset that combines them.
|
|
397
|
+
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:
|
|
398
|
+
|
|
399
|
+
- `maxFrames` — most frames, 1000 by default.
|
|
400
|
+
- `maxAnimationPixels` — most pixels of all frames, each counted at the larger of the source and the rendition size, 50 megapixels by default.
|
|
401
|
+
|
|
402
|
+
`0` removes a limit.
|
|
403
|
+
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.
|
|
404
|
+
Changing the limits keeps stored renditions as they are; bump `pipelineVersion` to render them again.
|
|
405
|
+
|
|
285
406
|
To process images with another library, implement the [`Processor`](#extending) interface and pass your implementation to `createRenditionServer()` as `processor`.
|
|
286
407
|
|
|
287
408
|
## h3 and other frameworks
|
|
@@ -307,7 +428,7 @@ It takes the same options without `getContext` and returns a function from the r
|
|
|
307
428
|
// server.ts
|
|
308
429
|
import { createRenditionHandler } from "image-renditions/server"
|
|
309
430
|
|
|
310
|
-
const renditions = createRenditionHandler({
|
|
431
|
+
const renditions = createRenditionHandler({ server })
|
|
311
432
|
|
|
312
433
|
export default {
|
|
313
434
|
fetch(request: Request) {
|
|
@@ -327,16 +448,34 @@ catalog.url("avatar", { path: "avatars/42.jpg" })
|
|
|
327
448
|
catalog.srcset("avatar", { scope: "tenant", path: "avatars/42.jpg" })
|
|
328
449
|
```
|
|
329
450
|
|
|
451
|
+
They throw a `RangeError` for an unsafe path; check stored paths with `isSafePath()` first.
|
|
452
|
+
With [originals](#originals) in the catalog, check `catalog.isOriginal()` and build the URL of such a source yourself:
|
|
453
|
+
|
|
454
|
+
```ts
|
|
455
|
+
function logoUrl(path: string) {
|
|
456
|
+
if (!isSafePath(path)) {
|
|
457
|
+
return undefined
|
|
458
|
+
}
|
|
459
|
+
if (catalog.isOriginal("logo", path)) {
|
|
460
|
+
return `https://uploads.example.com/${encodePath(path)}`
|
|
461
|
+
}
|
|
462
|
+
return catalog.url("logo", { path })
|
|
463
|
+
}
|
|
464
|
+
```
|
|
465
|
+
|
|
330
466
|
## Rendering outside requests
|
|
331
467
|
|
|
332
468
|
`server.ensure()` publishes one density of a preset without a request, for example right after an upload:
|
|
333
469
|
|
|
334
470
|
```ts
|
|
335
|
-
|
|
336
|
-
|
|
471
|
+
const path = "avatars/42.jpg"
|
|
472
|
+
for (const { recipe } of catalog.variants("avatar", path)) {
|
|
473
|
+
await server.ensure(recipe, { scope: "tenant", path }, { tenant: "acme" })
|
|
337
474
|
}
|
|
338
475
|
```
|
|
339
476
|
|
|
477
|
+
The path chooses the [animated](#animation) variants of sources that have them.
|
|
478
|
+
|
|
340
479
|
It resolves to the key and public URL of the rendition after it is written or found in storage.
|
|
341
480
|
|
|
342
481
|
## How a request is served
|
|
@@ -383,7 +522,8 @@ The handler responds to a failure with the status of its reason:
|
|
|
383
522
|
- `invalid-request` (404): malformed URL, `resolveSource` rejected the target, or a key is unsafe or too long for the store
|
|
384
523
|
- `unknown-recipe` (404): hash neither in the catalog nor known as published
|
|
385
524
|
- `source-not-found` (404): no source under the resolved key
|
|
386
|
-
- `unsupported-source` (422): source cannot be decoded or
|
|
525
|
+
- `unsupported-source` (422): source cannot be decoded or processed
|
|
526
|
+
- `source-too-large` (422): source exceeds a byte or pixel limit
|
|
387
527
|
- `source-error` (502): reading the source failed
|
|
388
528
|
- `processing-failed` (500): processing failed for a reason other than the source
|
|
389
529
|
- `storage-error` (502): checking for or writing the rendition failed
|
|
@@ -401,22 +541,25 @@ Otherwise the request fails as `unknown-recipe` without calling the stores, so m
|
|
|
401
541
|
|
|
402
542
|
```ts
|
|
403
543
|
export default defineRenditionEventHandler({
|
|
404
|
-
catalog,
|
|
405
544
|
server,
|
|
406
545
|
getContext,
|
|
407
|
-
onFailure({ reason, error,
|
|
546
|
+
async onFailure({ reason, error, target, context }) {
|
|
408
547
|
if (reason === "storage-error" || reason === "processing-failed") {
|
|
409
548
|
reportError(error)
|
|
410
549
|
}
|
|
411
|
-
if (reason === "unknown-recipe" &&
|
|
412
|
-
//
|
|
413
|
-
|
|
550
|
+
if (reason === "unknown-recipe" && target) {
|
|
551
|
+
// Here sources are public, so an outdated URL shows the original instead.
|
|
552
|
+
const { sourceKey } = await server.resolve(target, context)
|
|
553
|
+
return Response.redirect(`https://uploads.example.com/${encodePath(sourceKey)}`, 302)
|
|
414
554
|
}
|
|
415
555
|
return undefined
|
|
416
556
|
},
|
|
417
557
|
})
|
|
418
558
|
```
|
|
419
559
|
|
|
560
|
+
- `target` is the target of the URL, absent when the URL is malformed or its scope is not declared.
|
|
561
|
+
- `server.resolve()` returns the keys a target resolves to with `resolveSource`, after checking that they are safe.
|
|
562
|
+
|
|
420
563
|
Returning `undefined` keeps the default response.
|
|
421
564
|
Every failure response, including a replaced one, is sent with `Cache-Control: no-store`.
|
|
422
565
|
|
|
@@ -426,6 +569,7 @@ Implement an interface from `image-renditions/server` and pass your implementati
|
|
|
426
569
|
|
|
427
570
|
- To use other storage, implement `SourceStore` or `ResultStore` and pass it as `source` or `results`.
|
|
428
571
|
- To process images with another library, implement `Processor` and pass it as `processor`.
|
|
572
|
+
Implement its `assertRecipe()` to reject recipes it cannot render, such as animations, when the server is created.
|
|
429
573
|
- To share the index of published renditions between replicas, for example in Redis, implement `ReadinessIndex` and pass it as `readiness.shared`.
|
|
430
574
|
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
575
|
- To render each rendition once across replicas, implement `Coordinator` as a shared lock and pass it as `coordinator`.
|
|
@@ -439,6 +583,7 @@ The built-in stores and processor implement the same interfaces and serve as exa
|
|
|
439
583
|
Keep private images out of what `resolveSource` maps, for example by serving only the folders of public images.
|
|
440
584
|
- A source must never change under its key: renditions of a replaced source keep showing the old image.
|
|
441
585
|
Store new content under a new key.
|
|
442
|
-
- Deleting a source leaves its renditions in storage under its result prefix.
|
|
443
|
-
-
|
|
586
|
+
- Deleting a source leaves its renditions in storage under its result prefix, which `server.resolve()` returns.
|
|
587
|
+
- 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.
|
|
588
|
+
A preset shows such sources as they are when it lists their extensions in [`originals`](#originals).
|
|
444
589
|
- 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-9XP5SZhg.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