image-renditions
v0.9.0
Published
On-demand image transformations that persist every result to durable storage and keep app servers stateless.
Readme
image-renditions
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.
<RenditionImg preset="avatar" src="avatars/42.jpg" alt="" /><img
src="/_renditions/v1/avatars/42.jpg/avatar.faf7e221f65d.avif"
srcset="
/_renditions/v1/avatars/42.jpg/avatar.faf7e221f65d.avif 1x,
/_renditions/v1/avatars/42.jpg/[email protected] 2x
"
alt=""
/>The handler redirects such a URL to the stored file:
GET /_renditions/v1/avatars/42.jpg/avatar.faf7e221f65d.avif HTTP/1.1
HTTP/1.1 307 Temporary Redirect
Location: https://renditions.example.com/avatars/42.jpg/faf7e221f65d.avif- The first request for such a URL renders a 48×48 AVIF from
avatars/42.jpgand writes it to storage before the redirect. - Later requests redirect at once, and your bucket or CDN serves the image.
- Changing the
avatarpreset gives it new URLs, so no browser or CDN keeps showing the old version.
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.
The library is modular, with pluggable parts:
- HTTP: an event handler for h3 1.15 or newer and h3 2, and a framework-neutral handler for other servers.
- Processing: a processor built on sharp.
- Storage: an S3 bucket or a file system directory for uploads, with renditions in another one, in a folder of their own, or next to each upload.
- Nuxt: a Nuxt 4 module that registers the handler and the
<RenditionImg>component. - Replicas: a readiness index and a lock shared between replicas through Redis.
- Extending: other storage, image libraries, and state shared between replicas plug in through the interfaces of
image-renditions/server.
When not to use it
Use an image service such as imgproxy or Thumbor when the URL must choose the size, such as ?w=300.
Such a service takes the size and other options from the URL, and to keep clients from changing them it can require URLs signed with a secret key, which must stay out of browser code.
image-renditions renders only the presets of its catalog: a URL carries a hash of the preset options, and a request for any other hash renders nothing. Its URLs need no secret, so they can be built in the browser too, but every size must be a preset in your code.
Quick start
This example sets up image-renditions in a Nuxt app.
npm install image-renditions sharpsharp is needed by the built-in processor.
Define presets in a catalog.
Both the app and the server import it, so it must import nothing but image-renditions:
// shared/renditions.ts
import { defineCatalog } from "image-renditions"
export default defineCatalog({
presets: {
// 48×48 for regular screens and 96×96 for high-density ones.
avatar: { width: 48, height: 48, densities: [1, 2] },
cover: { width: 800, height: 200 },
},
})Create a handler for rendition URLs, for example one that reads uploads from the uploads bucket and writes renditions to the public renditions bucket:
// server/renditions.ts
import { S3Client } from "@aws-sdk/client-s3"
import { defineRenditionEventHandler } from "image-renditions/h3"
import { createS3Results, createS3Source } from "image-renditions/s3"
import { createRenditionServer } from "image-renditions/server"
import { createSharpProcessor } from "image-renditions/sharp"
import catalog from "#shared/renditions"
const client = new S3Client({ region: "us-east-1" })
const server = createRenditionServer({
catalog,
source: createS3Source({ client, bucket: "uploads" }),
results: createS3Results({
client,
bucket: "renditions",
publicUrl: "https://renditions.example.com",
}),
processor: createSharpProcessor(),
})
export default defineRenditionEventHandler({ server })A rendition of avatars/42.jpg is stored in renditions at avatars/42.jpg/<hash>.avif, and publicUrl is where browsers load it from.
Register the catalog and the handler:
// nuxt.config.ts
export default defineNuxtConfig({
modules: ["image-renditions/nuxt"],
renditions: {
catalog: "#shared/renditions",
handler: "~~/server/renditions",
},
})Show images:
<!-- app/components/UserAvatar.vue -->
<template>
<RenditionImg preset="avatar" :src="user.avatarPath" alt="" />
</template>user.avatarPath is the key of the upload in its bucket, such as avatars/42.jpg.
Preset names are type-checked against the catalog.
Presets
Options of a preset, or of defaults, which every preset overrides field by field:
width,height— size in CSS pixels at density 1; with one side set, the other keeps the aspect ratio.fit— how the image fills the box when both sides are set:cover(default) crops what overflows the box.containletterboxes the image inside the box.fillstretches the image to the box.insideandoutsidekeep the aspect ratio, so one side of the output is smaller or larger than the box.
position—center(default),top,bottom,left,right, or content-awareentropyandattentionforcover.enlarge— upscale sources smaller than the target; otherwise they keep their size.background— hex color forcontainletterboxing, transparent by default, and for flattening transparency into JPEG, white by default.format—avif(default),webp,jpegorpng.quality— encoder quality from 1 to 100, by default 50 foravifand 80 forwebpandjpeg;pngis lossless.effort— CPU effort of theavif(0–9) andwebp(0–6) encoders, 4 by default; other formats ignore it.densities— pixel densities to render, which include1;[1]by default.animation—first-frame(default) renders the first frame of animated sources, andpreservekeeps the animation of GIF and WebP sources; see Animation.originals— extensions of sources shown as they are, so no option above applies to them; see Originals.
Options of the catalog:
basePath— path the handler is mounted at,/_renditionsby default.pipelineVersion— bump it to render every image again, for example after switching to another processor.
Changing an option that affects the output gives the images of the preset new URLs, and they are rendered again on their first request. Renaming a preset keeps its stored renditions.
Showing images
<RenditionImg> renders src, and a srcset when the preset has several densities.
- It also renders
widthandheightwhen the image always has the preset size: withcontain, or withcoverandfillwhenenlargeis set. It renders neither for other presets, including ones that set a single side, so size their images with CSS. - A
srcthat is already a URL, such asblob:orhttps:, renders unchanged, for example a preview of a file being uploaded. - An unsafe path, such as one with a
..segment, renders nosrcinstead of breaking the page. - A source whose extension the preset lists in
originalsrenders as it is. - A GIF or WebP source of a preset with
animation: "preserve"renders its animated variants.
useRenditions() builds the same URLs in code, prefixing rendition URLs with app.baseURL:
const renditions = useRenditions()
renditions.url("cover", post.coverPath)
renditions.url("avatar", path, { density: 2 })
renditions.srcset("avatar", path)
renditions.attrs("avatar", path) // src, srcset and size of <RenditionImg>, for your own img element
renditions.attrs("avatar", path, { size: false }) // src and srcset, for a component that sizes the image itselfurl() and srcset() return undefined for an unsafe path.
Module options, under renditions:
catalog— module exporting the catalog by default; required.handler— optional module exporting the h3 handler by default, mounted at the catalog base path.defaultScope— optional scope for<RenditionImg>anduseRenditions().originalUrl— module building URLs of originals; required when a preset listsoriginals.imgAttrs— attributes every<RenditionImg>renders, such as{ loading: "lazy", decoding: "async" }. An attribute of the element overrides them, for exampleloading="eager"on an image above the fold.
For users coming from
@nuxt/image: this module is not an@nuxt/imageprovider, so instead of<NuxtImg>anduseImage(), use<RenditionImg>anduseRenditions()with catalog presets.
Scopes
The path an app stores for an upload, such as avatars/42.jpg, is often not its storage key.
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.
Scopes and resolveSource let the server decide where a path lives:
- The scope tells which space a path belongs to, such as
tenantorshared. One page may show images of several spaces, so each URL carries its scope. - The context holds what the request determines, such as the current tenant;
getContextderives it from the request, so a client cannot change it by editing a URL. resolveSourcemaps the scope, the path and the context to storage keys, or rejects them.
Without resolveSource, an unscoped path is the key itself, and scoped paths are rejected.
For example, with uploads of several tenants and files shared between them, declare both scopes in the catalog:
// shared/renditions.ts
export default defineCatalog({
scopes: ["tenant", "shared"],
presets: {
// ...
},
})- Scope names are type-checked against the catalog, like preset names.
catalog.url()requires a scope.useRenditions()and<RenditionImg>use the module'sdefaultScopewhen a call has no scope, and throw aRangeErrorwhen there is neither.- The server rejects a target with another scope or no scope as
invalid-request, soresolveSourcereceives only the declared scopes. ScopeName<typeof catalog>is the union of the scopes, andPresetName<typeof catalog>is the union of the preset names.
The catalog of the server types the scopes resolveSource receives, so it handles each of them:
// server/renditions.ts
const server = createRenditionServer({
catalog,
// source, results and processor as in the quick start
resolveSource({ scope, path }, { tenant }: { tenant: string }) {
return { sourceKey: scope === "tenant" ? `tenants/${tenant}/${path}` : `shared/${path}` }
},
})
export default defineRenditionEventHandler({
server,
// findTenant() is your lookup, for example by host.
getContext: async event => ({ tenant: await findTenant(event) }),
})- The handler calls
getContextat most once per request. It skips the call for a method other thanGETandHEAD, and, unlessonFailureis set, for a malformed URL or a scope the catalog does not declare. - If
resolveSourcereturnsundefined, the request fails with404. - Without scopes in the catalog, targets have no scope, and the server rejects a target with one as
invalid-request. - Map different scopes to different keys even for equal paths.
- To define
resolveSourceapart from the server, for example to test it, type it asSourceResolver<Context, ScopeName<typeof catalog>>fromimage-renditions/server. - A rendition is stored in the result store at
<sourceKey>/<hash>.<extension>. To keep renditions in the bucket of uploads, pass aprefixtocreateS3Results(). To lay them out per source, for example in the folder of each tenant, also return arenditionPrefixthat no source key starts with, to use instead ofsourceKey. - S3 limits a key to 1024 bytes in UTF-8, and the library writes a rendition under a key up to 18 bytes longer than its rendition prefix, plus the
prefixof the store and a slash. If source keys can come that close to the limit, return a shorterrenditionPrefix, or their renditions fail asinvalid-request.
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.
If the context depends on cookies or a session, pass redirectCacheControl: "no-store" to defineRenditionEventHandler(), or one tenant may get the rendition of another.
With the Nuxt module, defaultScope: "tenant" makes tenant the scope of every image, and scope selects another one; the build fails unless the catalog declares the default scope:
<RenditionImg preset="cover" :src="template.coverPath" scope="shared" alt="" />Originals
Some sources are better shown as they are: SVG stays crisp at any zoom, and createSharpProcessor() cannot decode ICO or BMP.
originals lists the extensions of such sources, in lowercase and without the dot:
export default defineCatalog({
defaults: { originals: ["svg", "ico", "bmp"] },
presets: {
logo: { height: 32 },
// Email clients do not show SVG.
"email-logo": { height: 32, originals: [] },
},
})For such a path, <RenditionImg> and useRenditions() give the URL of the source itself:
url()returns it for every declared density, andsrcset()returnsundefined.<RenditionImg>renders nosrcset,widthorheight, so size such images with CSS.gifin the list shows every GIF as it is, because a path does not tell an animated GIF from a still one. Leave it out and setanimation: "preserve"instead to shrink animated GIFs.- Only the extension is checked, not the content, and the server still renders the source when its rendition URL is requested.
- Changing
originalskeeps the hashes of the preset.
Only the app knows where its sources are served from, so it builds their URLs.
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.
defineRenditionOriginalUrl(), which the module auto-imports, types them by the catalog:
// app/renditions/original-url.ts
import { encodePath } from "image-renditions"
export default defineRenditionOriginalUrl(() => {
const { uploadsUrl } = useRuntimeConfig().public
return ({ path }) => `${uploadsUrl}/${encodePath(path)}`
})// nuxt.config.ts
export default defineNuxtConfig({
renditions: {
catalog: "#shared/renditions",
originalUrl: "~/renditions/original-url",
},
})useRenditions()calls the composable every time it is called, so the composable may use other composables.- 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. - The URL it returns is used as is, without
app.baseURL. - The build fails when a preset lists
originalsandoriginalUrlis not set.
<img>never runs scripts of an SVG, but an SVG opened as a page runs them on the site it is served from. Keep SVG files that users upload from running scripts as part of the app in any of these ways:
- Serve them from a separate domain rather than a subdomain of the app.
- Serve them with a
Content-Security-Policy: sandboxresponse header.- Store them with
Content-Disposition: attachment, which S3 keeps with the object, so browsers download such a file instead of opening it.
Animation
A rendition of an animated source shows its first frame.
animation: "preserve" keeps the animation of GIF and WebP sources instead:
export default defineCatalog({
presets: {
thumb: { width: 96, height: 96 },
content: { width: 800, densities: [1, 2], animation: "preserve" },
},
})- A path with the
giforwebpextension gets variants of its own, whichurl(),srcset()and<RenditionImg>choose by the extension, as a path does not tell an animated source from a still one. - These variants are WebP and take
qualityandeffortonly from a preset whoseformatiswebp, since values for another format do not suit WebP. - A still GIF or WebP source renders as a still WebP image.
- Only the extension is checked, and the server renders any source with these variants when their URL is requested.
- The variants of other sources keep their hashes, so only GIF and WebP sources render again.
originalstake precedence, so a preset that listsgifin them shows GIF sources as they are.
To render the animated variants ahead of a request, pass the source path to catalog.variants(), as in Rendering outside requests.
How much of an animation a rendition keeps depends on the processor; see Processing for createSharpProcessor().
createRenditionServer() fails when its processor cannot render a variant of the catalog.
Storage
The server reads sources from a source store and writes renditions to a result store, which may be of different kinds.
Both source stores fail sources larger than 50 MiB as source-too-large; pass maxBytes to createS3Source() or createFileSystemSource() to change the limit.
S3 storage
To keep uploads and renditions in S3 buckets, use the stores of image-renditions/s3:
// server/renditions.ts
import { S3Client } from "@aws-sdk/client-s3"
import { createS3Results, createS3Source } from "image-renditions/s3"
const client = new S3Client({ region: "us-east-1" })
const server = createRenditionServer({
catalog,
source: createS3Source({ client, bucket: "uploads" }),
results: createS3Results({
client,
bucket: "renditions",
publicUrl: "https://renditions.example.com",
}),
processor: createSharpProcessor(),
})The stores take a client of AWS SDK v3, the bare S3Client or the aggregated S3.
createS3Results() writes renditions with Cache-Control: public, max-age=31536000, immutable; pass it cacheControl to change the header.
The bucket of renditions must serve them publicly at publicUrl, directly or through a CDN.
To keep renditions in the bucket of uploads, pass createS3Results() a prefix folder that holds no uploads, such as renditions.
Renditions are then stored and served under it, and publicUrl stays the URL of the bucket root.
File storage
To keep uploads and renditions on a disk served by a web server, use the stores of image-renditions/fs:
// server/renditions.ts
import { createFileSystemResults, createFileSystemSource } from "image-renditions/fs"
const server = createRenditionServer({
catalog,
source: createFileSystemSource({ root: "/data/uploads" }),
results: createFileSystemResults({ root: "/data/renditions", publicUrl: "/renditions" }),
processor: createSharpProcessor(),
})location /renditions/ {
alias /data/renditions/;
add_header Cache-Control "public, max-age=31536000, immutable";
}Processing
createSharpProcessor() from image-renditions/sharp processes images with sharp.
Sources over 100 megapixels fail as source-too-large; pass maxInputPixels to createSharpProcessor() to change the limit, or 0 to remove it.
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.
Under cover, such a source is cropped instead, and its rendition keeps the preset size.
createSharpProcessor() keeps the animation of GIF and WebP sources.
It does not crop animations by entropy or attention, so a server fails to start with a preset that combines them.
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:
maxFrames— most frames, 1000 by default.maxAnimationPixels— most pixels of all frames, each counted at the larger of the source and the rendition size, 50 megapixels by default.
0 removes a limit.
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.
Changing the limits keeps stored renditions as they are; bump pipelineVersion to render them again.
When a preset keeps the animation and a GIF or WebP animation renders its first frame because of its EXIF orientation or a limit, createRenditionServer() passes it to onDegradation once the rendition is written.
The reason is animation-orientation, animation-frames or animation-pixels, and the message has the values involved, so you can log such renditions and tune the limits:
const server = createRenditionServer({
// ...
onDegradation({ message, target }) {
console.warn(`The rendition of ${target.path} is degraded: ${message}`)
},
})To process images with another library, implement the Processor interface and pass your implementation to createRenditionServer() as processor.
h3 and other frameworks
The handler from the quick start is a regular h3 event handler.
Mount it at <basePath>/**, /_renditions/** by default:
// server.ts
import { H3, serve } from "h3"
import renditions from "./renditions"
serve(new H3().all("/_renditions/**", renditions))With h3 v1, add it to a router: createRouter().use("/_renditions/**", renditions).
For other frameworks, use createRenditionHandler() from image-renditions/server.
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:
// server.ts
import { createRenditionHandler } from "image-renditions/server"
const renditions = createRenditionHandler({ server })
export default {
fetch(request: Request) {
const { pathname, search } = new URL(request.url)
// The second argument returns the context resolveSource receives, such as the current tenant.
return renditions({ method: request.method, path: pathname + search }, () => undefined)
},
}Build URLs in the app with the catalog, which is safe to import in browsers:
catalog.url("avatar", { path: "avatars/42.jpg" })
// /_renditions/v1/avatars/42.jpg/avatar.faf7e221f65d.avif
catalog.srcset("avatar", { path: "avatars/42.jpg" })They throw a RangeError for an unsafe path; check stored paths with isSafePath() first.
With originals in the catalog, check catalog.isOriginal() and build the URL of such a source yourself:
function logoUrl(path: string) {
if (!isSafePath(path)) {
return undefined
}
if (catalog.isOriginal("logo", path)) {
return `https://uploads.example.com/${encodePath(path)}`
}
return catalog.url("logo", { path })
}Rendering outside requests
server.ensure() publishes one density of a preset without a request, for example right after an upload:
const path = "avatars/42.jpg"
for (const { recipe } of catalog.variants("avatar", path)) {
await server.ensure(recipe, { scope: "tenant", path }, { tenant: "acme" })
}The path chooses the animated variants of sources that have them.
It resolves to the key and public URL of the rendition after it is written or found in storage.
How a request is served
/_renditions/v1/[<scope>/]<path>/<preset>.<hash>.<extension>A URL has a scope exactly when the catalog declares scopes. The hash identifies the preset options at one density, and the preset name only labels the URL. The handler looks up the hash in the catalog, resolves the source, and then:
- If the process or a shared readiness index remembers the rendition, it redirects right away.
- Otherwise it checks storage and redirects if the rendition is there.
- Otherwise it reads the source, renders it, writes the result, and redirects.
Within a process, equal concurrent requests are rendered once.
Replicas without a shared coordinator may each render the same rendition and write equal files under its key.
The redirect is a 307 with Cache-Control: public, max-age=86400, so browsers and CDNs remember it for a day.
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.
If publicUrl never changes, pass a longer redirectCacheControl to defineRenditionEventHandler().
Concurrency and timeouts
The limits option of createRenditionServer() bounds rendering per process:
concurrency— renditions processed at once, 2 by default.queueSize— renditions waiting for a slot, 100 by default.queueTimeout— milliseconds a rendition may wait for a slot, 30 seconds by default.storageTimeout— milliseconds a storage call may take, 15 seconds by default.indexTimeout— milliseconds a shared readiness index call may take, 1 second by default.
Requests beyond the queue, or waiting too long, fail as overloaded.
Processing itself has no timeout, because a timeout could not stop the processor and free its slot.
These limits bound image processing, not HTTP request rates. Requests for renditions missing from the readiness indexes check result storage, even when the source does not exist. On a public site, rate-limit requests per client at the CDN or reverse proxy.
Sharing state between replicas
Each process keeps its own state: it remembers the renditions it has published, and renders a rendition once for its concurrent requests.
With several replicas, image-renditions/redis shares this state through Redis:
- The readiness index lets a replica redirect to a rendition that another replica published, without checking storage.
- The coordinator lets one replica render a rendition while the others wait for it.
Create a sendCommand callback for your Redis or Valkey client:
// node-redis: createClient()
const sendCommand = (args: string[]) => redis.sendCommand(args)
// node-redis: createSentinel()
const sendCommand = (args: string[]) => sentinel.sendCommand(false, args)
// node-redis: createCluster()
const sendCommand = (args: string[], key: string) => cluster.sendCommand(key, false, args)
// ioredis or iovalkey: Redis or Cluster, including Sentinel connections
const sendCommand = ([command, ...args]: string[]) => redis.call(command!, args)
// Bun: RedisClient
const sendCommand = ([command, ...args]: string[]) => redis.send(command!, args)Pass it to createRedisReadinessIndex() and createRedisCoordinator():
// server/renditions.ts
import { createRedisCoordinator, createRedisReadinessIndex } from "image-renditions/redis"
const server = createRenditionServer({
// catalog, stores and processor as in the quick start
readiness: { shared: createRedisReadinessIndex({ sendCommand }) },
coordinator: createRedisCoordinator({ sendCommand, onError: reportError }),
})The readiness index forgets a rendition 30 days after it last added or found it, even if the rendition is still requested; pass ttl in milliseconds to change it.
A forgotten rendition costs one storage check while its recipe is in the catalog; after the recipe is removed, its outdated URL fails as unknown-recipe.
When the index fails or exceeds limits.indexTimeout, the server reports the error to its onError and checks storage instead.
The coordinator holds a lock in Redis while one replica renders a rendition, and other replicas wait for it to finish:
lockTtl— milliseconds a lock outlives a replica that stopped renewing it, such as a crashed one, 30 seconds by default.waitTimeout— milliseconds a replica waits for the lock before it renders the rendition anyway, 30 seconds by default.commandTimeout— milliseconds Redis may take to reply to an attempt to take the lock, 1 second by default. A replica may wait this long even whenwaitTimeoutis shorter.
The lock only saves work: when Redis fails or does not reply in time, the coordinator reports the error to onError and renders without the lock.
If a lock is lost, for example on a Sentinel failover, the worst case is that two replicas render the same rendition.
Keys start with image-renditions:; pass prefix to the readiness index and the coordinator to change it.
Errors
The handler responds to a failure with the status of its reason:
invalid-request(404): malformed URL,resolveSourcerejected the target, or a key is unsafe or too long for the storeunknown-recipe(404): hash neither in the catalog nor known as publishedsource-not-found(404): no source under the resolved keyunsupported-source(422): source cannot be decoded or processedsource-too-large(422): source exceeds a byte or pixel limitsource-error(502): reading the source failedprocessing-failed(500): processing failed for a reason other than the sourcestorage-error(502): checking for or writing the rendition failedoverloaded(503): processing queue is full or the wait timed out
Outdated URLs
A page rendered before a preset changed keeps requesting the old hash, which the catalog no longer has.
The handler serves it while the process or a shared readiness index remembers its rendition.
Otherwise the request fails as unknown-recipe without calling the stores, so made-up hashes cause no storage traffic either.
Custom responses
onFailure can report a failure or replace the response, for example with the original image:
export default defineRenditionEventHandler({
server,
getContext,
async onFailure({ reason, error, source }) {
if (reason === "storage-error" || reason === "processing-failed") {
reportError(error)
}
if (reason === "unknown-recipe" && source) {
// Here sources are public, so an outdated URL shows the original instead.
return Response.redirect(`https://uploads.example.com/${encodePath(source.sourceKey)}`, 302)
}
return undefined
},
})targetis the target of the URL, absent when the URL is malformed or its scope is not declared.sourceholds the keys of the target, absent when the request failed before the server resolved them and checked that they are safe; the source need not exist.server.resolve()returns the keys of any target, asresolveSourcegives them after checking that they are safe.
Returning undefined keeps the default response.
Every failure response, including a replaced one, is sent with Cache-Control: no-store.
Extending
Implement an interface from image-renditions/server and pass your implementation to createRenditionServer():
- To use other storage, implement
SourceStoreorResultStoreand pass it assourceorresults. - To process images with another library, implement
Processorand pass it asprocessor. Implement itsassertRecipe()to reject recipes it cannot render, such as animations, when the server is created. Returndegradationfrom itsprocess()to report an image that falls short of its recipe toonDegradation. - To share the index of published renditions between replicas in a database other than Redis, implement
ReadinessIndexand pass it asreadiness.shared. If a call fails or exceedsindexTimeout, the server reports the error toonErrorand checks storage instead, while outdated URLs fail asunknown-recipe. - To render each rendition once across replicas without Redis, implement
Coordinatoras a shared lock and pass it ascoordinator. Bound the wait for the lock, because the server sets no timeout on it.
The built-in stores, processor, readiness index and coordinator implement the same interfaces and serve as examples.
Limitations
- Renditions are public, and anyone who knows a path that
resolveSourceserves can get its renditions. Keep private images out of whatresolveSourcemaps, for example by serving only the folders of public images. - A source must never change under its key: renditions of a replaced source keep showing the old image. Store new content under a new key.
- Deleting a source leaves its renditions in storage under its rendition prefix, which
server.resolve()returns relative to the result store. With theprefixofcreateS3Results(), their S3 keys start with<prefix>/<renditionPrefix>/. - A rendition of an animated source shows its first frame unless the preset sets
animation: "preserve", a rendition of an SVG source is rasterized at the target size, and EXIF orientation is applied. A preset shows such sources as they are when it lists their extensions inoriginals. - Source paths are limited to 1024 characters.
