image-renditions 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +588 -2
- package/dist/catalog-D11xkViN.d.mts +130 -0
- package/dist/error-DViVpVmH.mjs +12 -0
- package/dist/fs.d.mts +25 -0
- package/dist/fs.mjs +50 -0
- package/dist/h3.d.mts +27 -0
- package/dist/h3.mjs +25 -0
- package/dist/handler-9XP5SZhg.d.mts +227 -0
- package/dist/handler-CPJllrYi.mjs +78 -0
- package/dist/img-attrs-BoCAHpl5.mjs +14 -0
- package/dist/index.d.mts +23 -0
- package/dist/index.mjs +123 -0
- package/dist/nuxt/runtime.d.mts +90 -0
- package/dist/nuxt/runtime.mjs +129 -0
- package/dist/nuxt.d.mts +31 -0
- package/dist/nuxt.mjs +60 -0
- package/dist/path-hNFUjCbA.mjs +22 -0
- package/dist/processor-DvyPs-OZ.d.mts +22 -0
- package/dist/recipe-BT8D60Qb.d.mts +104 -0
- package/dist/recipe-BavgOq0b.mjs +142 -0
- package/dist/s3.d.mts +26 -0
- package/dist/s3.mjs +81 -0
- package/dist/scopes-C1kzqQHz.mjs +22 -0
- package/dist/server.d.mts +4 -0
- package/dist/server.mjs +218 -0
- package/dist/sharp.d.mts +22 -0
- package/dist/sharp.mjs +205 -0
- package/dist/stores-P-TUmax4.d.mts +42 -0
- package/dist/url-BAU5GYXC.mjs +81 -0
- package/dist/url-BVH3ZB0F.d.mts +38 -0
- package/package.json +89 -4
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,589 @@
|
|
|
1
|
-
#
|
|
1
|
+
# image-renditions
|
|
2
2
|
|
|
3
|
-
|
|
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 { S3Client } 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 S3Client({ region: "us-east-1" })
|
|
74
|
+
|
|
75
|
+
const server = createRenditionServer({
|
|
76
|
+
catalog,
|
|
77
|
+
source: createS3Source({ client, bucket: "uploads" }),
|
|
78
|
+
results: createS3Results({
|
|
79
|
+
client,
|
|
80
|
+
bucket: "renditions",
|
|
81
|
+
publicUrl: "https://renditions.example.com",
|
|
82
|
+
}),
|
|
83
|
+
processor: createSharpProcessor(),
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
export default defineRenditionEventHandler({ server })
|
|
87
|
+
```
|
|
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.
|
|
90
|
+
|
|
91
|
+
Register the catalog and the handler:
|
|
92
|
+
|
|
93
|
+
```ts
|
|
94
|
+
// nuxt.config.ts
|
|
95
|
+
export default defineNuxtConfig({
|
|
96
|
+
modules: ["image-renditions/nuxt"],
|
|
97
|
+
renditions: {
|
|
98
|
+
catalog: "#shared/renditions",
|
|
99
|
+
handler: "~~/server/renditions",
|
|
100
|
+
},
|
|
101
|
+
})
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Show images:
|
|
105
|
+
|
|
106
|
+
```vue
|
|
107
|
+
<!-- app/components/UserAvatar.vue -->
|
|
108
|
+
<template>
|
|
109
|
+
<RenditionImg preset="avatar" :src="user.avatarPath" alt="" />
|
|
110
|
+
</template>
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
`user.avatarPath` is the key of the upload in its bucket, such as `avatars/42.jpg`.
|
|
114
|
+
Preset names are type-checked against the catalog.
|
|
115
|
+
|
|
116
|
+
## Presets
|
|
117
|
+
|
|
118
|
+
Options of a preset, or of `defaults`, which every preset overrides field by field:
|
|
119
|
+
|
|
120
|
+
- `width`, `height` — size in CSS pixels at density 1; with one side set, the other keeps the aspect ratio.
|
|
121
|
+
- `fit` — how the image fills the box when both sides are set:
|
|
122
|
+
- `cover` (default) crops what overflows the box.
|
|
123
|
+
- `contain` letterboxes the image inside the box.
|
|
124
|
+
- `fill` stretches the image to the box.
|
|
125
|
+
- `inside` and `outside` keep the aspect ratio, so one side of the output is smaller or larger than the box.
|
|
126
|
+
- `position` — `center` (default), `top`, `bottom`, `left`, `right`, or content-aware `entropy` and `attention` for `cover`.
|
|
127
|
+
- `enlarge` — upscale sources smaller than the target; otherwise they keep their size.
|
|
128
|
+
- `background` — hex color for `contain` letterboxing, transparent by default, and for flattening transparency into JPEG, white by default.
|
|
129
|
+
- `format` — `avif` (default), `webp`, `jpeg` or `png`.
|
|
130
|
+
- `quality` — encoder quality from 1 to 100, by default 50 for `avif` and 80 for `webp` and `jpeg`; `png` is lossless.
|
|
131
|
+
- `effort` — CPU effort of the `avif` (0–9) and `webp` (0–6) encoders, 4 by default; other formats ignore it.
|
|
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).
|
|
135
|
+
|
|
136
|
+
Options of the catalog:
|
|
137
|
+
|
|
138
|
+
- `basePath` — path the handler is mounted at, `/_renditions` by default.
|
|
139
|
+
- `pipelineVersion` — bump it to render every image again, for example after switching to another processor.
|
|
140
|
+
|
|
141
|
+
Changing an option that affects the output gives the images of the preset new URLs, and they are rendered again on their first request.
|
|
142
|
+
Renaming a preset keeps its stored renditions.
|
|
143
|
+
|
|
144
|
+
## Showing images
|
|
145
|
+
|
|
146
|
+
`<RenditionImg>` renders `src`, and a `srcset` when the preset has several densities.
|
|
147
|
+
|
|
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.
|
|
149
|
+
It renders neither for other presets, including ones that set a single side, so size their images with CSS.
|
|
150
|
+
- A `src` that is already a URL, such as `blob:` or `https:`, renders unchanged, for example a preview of a file being uploaded.
|
|
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.
|
|
154
|
+
|
|
155
|
+
`useRenditions()` builds the same URLs in code, prefixing rendition URLs with `app.baseURL`:
|
|
156
|
+
|
|
157
|
+
```ts
|
|
158
|
+
const renditions = useRenditions()
|
|
159
|
+
|
|
160
|
+
renditions.url("cover", post.coverPath)
|
|
161
|
+
renditions.url("avatar", path, { density: 2 })
|
|
162
|
+
renditions.srcset("avatar", path)
|
|
163
|
+
renditions.attrs("avatar", path) // src, srcset and size of <RenditionImg>, for your own img element
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
`url()` and `srcset()` return `undefined` for an unsafe path.
|
|
167
|
+
|
|
168
|
+
Module options, under `renditions`:
|
|
169
|
+
|
|
170
|
+
- `catalog` — module exporting the catalog by default; required.
|
|
171
|
+
- `handler` — optional module exporting the h3 handler by default, mounted at the catalog base path.
|
|
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.
|
|
176
|
+
|
|
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.
|
|
178
|
+
|
|
179
|
+
## Scopes
|
|
180
|
+
|
|
181
|
+
The path an app stores for an upload, such as `avatars/42.jpg`, is often not its storage key.
|
|
182
|
+
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.
|
|
183
|
+
|
|
184
|
+
Scopes and `resolveSource` let the server decide where a path lives:
|
|
185
|
+
|
|
186
|
+
- The scope tells which space a path belongs to, such as `tenant` or `shared`.
|
|
187
|
+
One page may show images of several spaces, so each URL carries its scope.
|
|
188
|
+
- 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.
|
|
189
|
+
- `resolveSource` maps the scope, the path and the context to storage keys, or rejects them.
|
|
190
|
+
|
|
191
|
+
Without `resolveSource`, an unscoped path is the key itself, and scoped paths are rejected.
|
|
192
|
+
|
|
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:
|
|
212
|
+
|
|
213
|
+
```ts
|
|
214
|
+
// server/renditions.ts
|
|
215
|
+
const server = createRenditionServer({
|
|
216
|
+
catalog,
|
|
217
|
+
// source, results and processor as in the quick start
|
|
218
|
+
resolveSource({ scope, path }, { tenant }: { tenant: string }) {
|
|
219
|
+
return { sourceKey: scope === "tenant" ? `tenants/${tenant}/${path}` : `shared/${path}` }
|
|
220
|
+
},
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
export default defineRenditionEventHandler({
|
|
224
|
+
server,
|
|
225
|
+
// findTenant() is your lookup, for example by host.
|
|
226
|
+
getContext: async event => ({ tenant: await findTenant(event) }),
|
|
227
|
+
})
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
- If `resolveSource` returns `undefined`, the request fails with `404`.
|
|
231
|
+
- Without scopes in the catalog, `resolveSource` receives any scope a URL has, or none, and rejects the ones it does not serve.
|
|
232
|
+
- Map different scopes to different keys even for equal paths.
|
|
233
|
+
- A rendition is stored in the result store at `<sourceKey>/<hash>.<extension>`.
|
|
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`.
|
|
235
|
+
- S3 limits a key to 1024 bytes in UTF-8, and a rendition key adds up to 38 bytes to its result prefix.
|
|
236
|
+
If source keys can come that close to the limit, return a shorter `resultPrefix`, or their renditions fail as `invalid-request`.
|
|
237
|
+
|
|
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.
|
|
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.
|
|
240
|
+
|
|
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:
|
|
242
|
+
|
|
243
|
+
```vue
|
|
244
|
+
<RenditionImg preset="cover" :src="template.coverPath" scope="shared" alt="" />
|
|
245
|
+
```
|
|
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
|
+
|
|
333
|
+
## Storage
|
|
334
|
+
|
|
335
|
+
The server reads sources from a source store and writes renditions to a result store, which may be of different kinds.
|
|
336
|
+
Both source stores fail sources larger than 50 MiB as `source-too-large`; pass `maxBytes` to `createS3Source()` or `createFileSystemSource()` to change the limit.
|
|
337
|
+
|
|
338
|
+
### S3 storage
|
|
339
|
+
|
|
340
|
+
To keep uploads and renditions in S3 buckets, use the stores of `image-renditions/s3`:
|
|
341
|
+
|
|
342
|
+
```ts
|
|
343
|
+
// server/renditions.ts
|
|
344
|
+
import { S3Client } from "@aws-sdk/client-s3"
|
|
345
|
+
import { createS3Results, createS3Source } from "image-renditions/s3"
|
|
346
|
+
|
|
347
|
+
const client = new S3Client({ region: "us-east-1" })
|
|
348
|
+
|
|
349
|
+
const server = createRenditionServer({
|
|
350
|
+
catalog,
|
|
351
|
+
source: createS3Source({ client, bucket: "uploads" }),
|
|
352
|
+
results: createS3Results({
|
|
353
|
+
client,
|
|
354
|
+
bucket: "renditions",
|
|
355
|
+
publicUrl: "https://renditions.example.com",
|
|
356
|
+
}),
|
|
357
|
+
processor: createSharpProcessor(),
|
|
358
|
+
})
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
The stores take a client of AWS SDK v3, the bare `S3Client` or the aggregated `S3`.
|
|
362
|
+
`createS3Results()` writes renditions with `Cache-Control: public, max-age=31536000, immutable`; pass it `cacheControl` to change the header.
|
|
363
|
+
The bucket of renditions must serve them publicly at `publicUrl`, directly or through a CDN.
|
|
364
|
+
|
|
365
|
+
### File storage
|
|
366
|
+
|
|
367
|
+
To keep uploads and renditions on a disk served by a web server, use the stores of `image-renditions/fs`:
|
|
368
|
+
|
|
369
|
+
```ts
|
|
370
|
+
// server/renditions.ts
|
|
371
|
+
import { createFileSystemResults, createFileSystemSource } from "image-renditions/fs"
|
|
372
|
+
|
|
373
|
+
const server = createRenditionServer({
|
|
374
|
+
catalog,
|
|
375
|
+
source: createFileSystemSource({ root: "/data/uploads" }),
|
|
376
|
+
results: createFileSystemResults({ root: "/data/renditions", publicUrl: "/renditions" }),
|
|
377
|
+
processor: createSharpProcessor(),
|
|
378
|
+
})
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
```nginx
|
|
382
|
+
location /renditions/ {
|
|
383
|
+
alias /data/renditions/;
|
|
384
|
+
add_header Cache-Control "public, max-age=31536000, immutable";
|
|
385
|
+
}
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
## Processing
|
|
389
|
+
|
|
390
|
+
`createSharpProcessor()` from `image-renditions/sharp` processes images with [sharp](https://sharp.pixelplumbing.com).
|
|
391
|
+
Sources over 100 megapixels fail as `source-too-large`; pass `maxInputPixels` to `createSharpProcessor()` to change the limit, or `0` to remove it.
|
|
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.
|
|
393
|
+
Under `cover`, such a source is cropped instead, and its rendition keeps the preset size.
|
|
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
|
+
|
|
406
|
+
To process images with another library, implement the [`Processor`](#extending) interface and pass your implementation to `createRenditionServer()` as `processor`.
|
|
407
|
+
|
|
408
|
+
## h3 and other frameworks
|
|
409
|
+
|
|
410
|
+
The handler from the quick start is a regular h3 event handler.
|
|
411
|
+
Mount it at `<basePath>/**`, `/_renditions/**` by default:
|
|
412
|
+
|
|
413
|
+
```ts
|
|
414
|
+
// server.ts
|
|
415
|
+
import { H3, serve } from "h3"
|
|
416
|
+
|
|
417
|
+
import renditions from "./renditions"
|
|
418
|
+
|
|
419
|
+
serve(new H3().all("/_renditions/**", renditions))
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
With h3 v1, add it to a router: `createRouter().use("/_renditions/**", renditions)`.
|
|
423
|
+
|
|
424
|
+
For other frameworks, use `createRenditionHandler()` from `image-renditions/server`.
|
|
425
|
+
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:
|
|
426
|
+
|
|
427
|
+
```ts
|
|
428
|
+
// server.ts
|
|
429
|
+
import { createRenditionHandler } from "image-renditions/server"
|
|
430
|
+
|
|
431
|
+
const renditions = createRenditionHandler({ server })
|
|
432
|
+
|
|
433
|
+
export default {
|
|
434
|
+
fetch(request: Request) {
|
|
435
|
+
const { pathname, search } = new URL(request.url)
|
|
436
|
+
// The second argument returns the context resolveSource receives, such as the current tenant.
|
|
437
|
+
return renditions({ method: request.method, path: pathname + search }, () => undefined)
|
|
438
|
+
},
|
|
439
|
+
}
|
|
440
|
+
```
|
|
441
|
+
|
|
442
|
+
Build URLs in the app with the catalog, which is safe to import in browsers:
|
|
443
|
+
|
|
444
|
+
```ts
|
|
445
|
+
catalog.url("avatar", { path: "avatars/42.jpg" })
|
|
446
|
+
// /_renditions/v1/avatar.faf7e221f65dd97945e352a57e805252.avif/avatars/42.jpg
|
|
447
|
+
|
|
448
|
+
catalog.srcset("avatar", { scope: "tenant", path: "avatars/42.jpg" })
|
|
449
|
+
```
|
|
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
|
+
|
|
466
|
+
## Rendering outside requests
|
|
467
|
+
|
|
468
|
+
`server.ensure()` publishes one density of a preset without a request, for example right after an upload:
|
|
469
|
+
|
|
470
|
+
```ts
|
|
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" })
|
|
474
|
+
}
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
The path chooses the [animated](#animation) variants of sources that have them.
|
|
478
|
+
|
|
479
|
+
It resolves to the key and public URL of the rendition after it is written or found in storage.
|
|
480
|
+
|
|
481
|
+
## How a request is served
|
|
482
|
+
|
|
483
|
+
```text
|
|
484
|
+
/_renditions/v1/[<scope>/]<preset>.<hash>.<extension>/<path>
|
|
485
|
+
```
|
|
486
|
+
|
|
487
|
+
The hash identifies the preset options at one density, and the preset name only labels the URL.
|
|
488
|
+
The handler looks up the hash in the catalog, resolves the source, and then:
|
|
489
|
+
|
|
490
|
+
1. If the process or a shared readiness index remembers the rendition, it redirects right away.
|
|
491
|
+
2. Otherwise it checks storage and redirects if the rendition is there.
|
|
492
|
+
3. Otherwise it reads the source, renders it, writes the result, and redirects.
|
|
493
|
+
|
|
494
|
+
Within a process, equal concurrent requests are rendered once.
|
|
495
|
+
Replicas without a shared `coordinator` may each render the same rendition and write equal files under its key.
|
|
496
|
+
|
|
497
|
+
The redirect is a `307` with `Cache-Control: public, max-age=86400`, so browsers and CDNs remember it for a day.
|
|
498
|
+
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.
|
|
499
|
+
If `publicUrl` never changes, pass a longer `redirectCacheControl` to `defineRenditionEventHandler()`.
|
|
500
|
+
|
|
501
|
+
## Concurrency and timeouts
|
|
502
|
+
|
|
503
|
+
Rendering takes the CPU and memory of the app process, so the `limits` option of `createRenditionServer()` bounds it per process:
|
|
504
|
+
|
|
505
|
+
- `concurrency` — renditions processed at once, 2 by default.
|
|
506
|
+
- `queueSize` — renditions waiting for a slot, 100 by default.
|
|
507
|
+
- `queueTimeout` — milliseconds a rendition may wait for a slot, 30 seconds by default.
|
|
508
|
+
- `storageTimeout` — milliseconds a storage call may take, 15 seconds by default.
|
|
509
|
+
- `indexTimeout` — milliseconds a shared readiness index call may take, 1 second by default.
|
|
510
|
+
|
|
511
|
+
Requests beyond the queue, or waiting too long, fail as `overloaded`.
|
|
512
|
+
Processing itself has no timeout, because a timeout could not stop the processor and free its slot.
|
|
513
|
+
|
|
514
|
+
These limits bound image processing, not HTTP request rates.
|
|
515
|
+
Requests for renditions missing from the readiness indexes check result storage, even when the source does not exist.
|
|
516
|
+
On a public site, rate-limit requests per client at the CDN or reverse proxy.
|
|
517
|
+
|
|
518
|
+
## Errors
|
|
519
|
+
|
|
520
|
+
The handler responds to a failure with the status of its reason:
|
|
521
|
+
|
|
522
|
+
- `invalid-request` (404): malformed URL, `resolveSource` rejected the target, or a key is unsafe or too long for the store
|
|
523
|
+
- `unknown-recipe` (404): hash neither in the catalog nor known as published
|
|
524
|
+
- `source-not-found` (404): no source under the resolved key
|
|
525
|
+
- `unsupported-source` (422): source cannot be decoded or processed
|
|
526
|
+
- `source-too-large` (422): source exceeds a byte or pixel limit
|
|
527
|
+
- `source-error` (502): reading the source failed
|
|
528
|
+
- `processing-failed` (500): processing failed for a reason other than the source
|
|
529
|
+
- `storage-error` (502): checking for or writing the rendition failed
|
|
530
|
+
- `overloaded` (503): processing queue is full or the wait timed out
|
|
531
|
+
|
|
532
|
+
### Outdated URLs
|
|
533
|
+
|
|
534
|
+
A page rendered before a preset changed keeps requesting the old hash, which the catalog no longer has.
|
|
535
|
+
The handler serves it while the process or a shared readiness index remembers its rendition.
|
|
536
|
+
Otherwise the request fails as `unknown-recipe` without calling the stores, so made-up hashes cause no storage traffic either.
|
|
537
|
+
|
|
538
|
+
### Custom responses
|
|
539
|
+
|
|
540
|
+
`onFailure` can report a failure or replace the response, for example with the original image:
|
|
541
|
+
|
|
542
|
+
```ts
|
|
543
|
+
export default defineRenditionEventHandler({
|
|
544
|
+
server,
|
|
545
|
+
getContext,
|
|
546
|
+
async onFailure({ reason, error, target, context }) {
|
|
547
|
+
if (reason === "storage-error" || reason === "processing-failed") {
|
|
548
|
+
reportError(error)
|
|
549
|
+
}
|
|
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)
|
|
554
|
+
}
|
|
555
|
+
return undefined
|
|
556
|
+
},
|
|
557
|
+
})
|
|
558
|
+
```
|
|
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
|
+
|
|
563
|
+
Returning `undefined` keeps the default response.
|
|
564
|
+
Every failure response, including a replaced one, is sent with `Cache-Control: no-store`.
|
|
565
|
+
|
|
566
|
+
## Extending
|
|
567
|
+
|
|
568
|
+
Implement an interface from `image-renditions/server` and pass your implementation to `createRenditionServer()`:
|
|
569
|
+
|
|
570
|
+
- To use other storage, implement `SourceStore` or `ResultStore` and pass it as `source` or `results`.
|
|
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.
|
|
573
|
+
- To share the index of published renditions between replicas, for example in Redis, implement `ReadinessIndex` and pass it as `readiness.shared`.
|
|
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`.
|
|
575
|
+
- To render each rendition once across replicas, implement `Coordinator` as a shared lock and pass it as `coordinator`.
|
|
576
|
+
Bound the wait for the lock, because the server sets no timeout on it.
|
|
577
|
+
|
|
578
|
+
The built-in stores and processor implement the same interfaces and serve as examples.
|
|
579
|
+
|
|
580
|
+
## Limitations
|
|
581
|
+
|
|
582
|
+
- Renditions are public, and anyone who knows a path that `resolveSource` serves can get its renditions.
|
|
583
|
+
Keep private images out of what `resolveSource` maps, for example by serving only the folders of public images.
|
|
584
|
+
- A source must never change under its key: renditions of a replaced source keep showing the old image.
|
|
585
|
+
Store new content under a new key.
|
|
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).
|
|
589
|
+
- Source paths are limited to 1024 characters.
|