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