Image pipeline — build-time variants + runtime endpoint + signed cache
This spec encodes [[rfc-bun-image]]. It introduces a next/image-class
This spec encodes [[rfc-bun-image]]. It introduces a next/image-class image pipeline built on Bun.Image: build-time variant generation for known assets, a <Image> React component, a signed runtime endpoint for on-demand transforms, and an on-disk LRU cache shared between both paths.
The patties value: same capability next/image ships, without the 30+MB Sharp native addon. Bun ships image decode/encode in core.
Goal
<Image> is the boring default for serving images. It produces responsive AVIF/WebP/JPEG srcsets, lazy-loads by default, and works without a config file. Static assets get pre-generated variants at build time; user-uploaded or dynamic URLs go through a signed runtime endpoint that resizes on demand and caches to disk.
Surface
<Image> component
import { Image } from "patties/render"
<Image
src="/photo.jpg" // static asset path OR external URL
alt="Cat in a box"
width={800} // intrinsic
height={600} // intrinsic
sizes="(max-width: 768px) 100vw, 50vw"
priority={false} // skip lazy-load
quality={80} // 1–100; default 75
formats={["avif", "webp"]} // optional; default platform-tuned
/>
Renders to:
<img
src="/_patties/images/photo.<hash>.640.jpeg"
srcset="...320.avif 320w, ...640.avif 640w, ...1280.avif 1280w"
sizes="..."
loading="lazy"
decoding="async"
width="800" height="600"
alt="Cat in a box"
/>
For static assets known to the build, all URLs in the srcset point to pre-generated files in .patties/images/. For unknown URLs (e.g. a user-uploaded path like /uploads/123.jpg), srcset URLs point at the signed runtime endpoint.
Runtime endpoint
GET /_patties/image?src=<path>&w=<width>&fmt=<format>&q=<quality>&sig=<hmac>
src— URL-encoded path. Allowed sources: project-relative paths (/uploads/...), trusted external origins (configured allowlist), or build-known paths.w— target width.fmt— output format (avif/webp/jpeg/png).q— quality (1–100; clamped).sig— base64url HMAC-SHA256 of${src}|${w}|${fmt}|${q}using the configured signing secret.
Unsigned or bad-signature requests return 400 immediately. This defends against the cache-stampede / disk-fill attack vector where a caller enumerates a billion (src, w) combos to exhaust the cache.
Configuration
// patties.config.ts
export default defineConfig({
images: {
domains: ["cdn.example.com"], // external origin allowlist
formats: ["avif", "webp"], // preferred output formats
qualities: [50, 75, 90], // allowed q values
widths: [320, 640, 1280, 2560], // srcset breakpoints
cacheDir: ".patties/image-cache", // disk cache location
cacheMaxBytes: 1024 * 1024 * 1024, // 1 GiB default
signingSecret: undefined, // resolved from Bun.secrets → env → here
},
})
All fields optional; defaults shown.
Build behaviour
Scan + pre-generate
When mode === "production" (or always-on in dev — see Dev mode below), the build (src/build/images.ts) walks app/public/**/*.{png,jpg,jpeg,webp,avif}. For each image:
- Decode via
Bun.Image.from(file)to get intrinsic dimensions. - For each
(width, format)pair from config, encode a variant. - Hash the source bytes via
Bun.CryptoHasher("xxh64"). - Write to
.patties/images/<basename>.<hash>.<w>.<fmt>. - Record in
IMAGE_MANIFEST.
// .patties/image-manifest.ts (generated, inlined via macro per spec 04)
export const IMAGE_MANIFEST: Record<string, ImageManifestEntry> = {
"/photo.jpg": {
intrinsic: { width: 2560, height: 1920 },
variants: [
{ w: 320, fmt: "avif", url: "/_patties/images/photo.abc123.320.avif" },
{ w: 640, fmt: "avif", url: "/_patties/images/photo.abc123.640.avif" },
// ...
],
contentType: "image/jpeg",
},
}
Manifest inlining
IMAGE_MANIFEST rides the existing Bun-macro pattern (spec 04 § Server bundle): the server entry imports it with { type: "macro" } so the manifest is inlined into the production bundle. No runtime Bun.Glob of .patties/images/.
Runtime behaviour
<Image> render
For a src present in IMAGE_MANIFEST, render <img srcset> pointing at the pre-generated variants. The src attribute is the mid-range JPEG fallback for browsers that don't support srcset.
For a src not in the manifest:
- If the path is project-relative or matches
images.domains, render<img srcset>pointing at the signed runtime endpoint (one URL per width × preferred format). - Otherwise, fail at build time (typecheck) with a clear error.
Endpoint flow
// src/server/image-endpoint.ts
async function serveImage(req: Request): Promise<Response> {
const url = new URL(req.url)
const params = parseImageParams(url.searchParams)
if (!verifyHmac(params, getSigningSecret())) return new Response(null, { status: 400 })
const cacheKey = `${params.src}|${params.w}|${params.fmt}|${params.q}`
const cachePath = `${config.images.cacheDir}/${hashCacheKey(cacheKey)}.${params.fmt}`
const cached = Bun.file(cachePath)
if (await cached.exists()) {
touchAtime(cachePath) // for LRU
return new Response(cached, headers(params.fmt))
}
const sourceBytes = await resolveSource(params.src)
const transformed = await Bun.Image.from(sourceBytes)
.resize({ width: params.w })
.encode(params.fmt, { quality: params.q })
await Bun.write(cachePath, transformed)
await maybeEvict(config.images.cacheDir, config.images.cacheMaxBytes)
return new Response(transformed, headers(params.fmt))
}
The endpoint is mounted at /_patties/image by the framework — it's a framework route, not user middleware.
Cache eviction
maybeEvict is called after every cache write. Implementation:
- Tally cache-dir size (cheap on Bun via
Bun.file(...).size). - If over
cacheMaxBytes, list files sorted byatimeascending, delete oldest until under the limit minus a 10% margin.
This is a basic disk-LRU. Workers / edge deploys (which don't have a writable filesystem) disable the runtime endpoint entirely — see Adapter behaviour below.
Signing secret
Source precedence (first non-empty wins):
Bun.secrets.get("patties.images.signing")(Bun adapter only).process.env.PATTIES_IMAGE_SIGNING_SECRET.images.signingSecretinpatties.config.ts.
If none found, the framework derives one at boot from a hash of (projectName, buildHash) and warns once per process. This keeps dev frictionless but makes the warning visible enough that users add a real secret before deploying.
Secret rotation: an array signingSecrets: [current, ...old] accepts all values for verify and uses index 0 for signing. Same pattern as [[rfc-bun-cookie-signing]] proposes for cookies (deferred).
Adapter behaviour
bun adapter
Full support: build-time variants + runtime endpoint + disk cache. The endpoint mounts on Bun.serve alongside framework routes.
edge adapter
Build-time variants only. The runtime endpoint is disabled — WinterCG runtimes can't reliably write to disk, and per-request image transform on a CPU-limited Worker is the wrong shape. The edge adapter falls back to "all srcset URLs must be in the manifest" — if <Image> is asked to render a non-manifest src, it logs a warning and renders the source src directly.
Deploy plugins for vendor image CDNs (Cloudflare Image Resizing, Vercel Image Optimization) can override this by registering an image-url hook that rewrites srcset URLs to vendor endpoints — spec'd as part of the deploy-plugin contract in [[framework/archive/phase-2/12-edge-adapters|spec 12]].
Dev mode
- Build-time variants run during
patties devboot and on file change (viabun --watchtriggering the build). - Runtime endpoint is enabled with a dev-only signing secret derived from a stable hash of the project path — no env var needed for first-run.
- Cache writes happen in
.patties/image-cache/exactly as in prod, so refreshes are fast after the first render.
Non-goals
- Vendor CDN integration. Cloudflare Image Resizing / Vercel Image Optimization belong in deploy plugins, not core.
- GIF / animated image support. Bun.Image's animation story is not landed; out of scope until it is.
- SVG transforms. SVG is rendered as-is via
<img src>; no resize / encode pipeline. Use raster sources for srcsets. - Background-image / CSS pipeline. This spec only covers
<img>tags. CSS image refs are unchanged. fill/intrinsiclayout modes. v1 shipswidth+height(intrinsic), no layout-mode prop. Add later if needed.
Acceptance criteria
- A project with
app/public/photo.jpgand<Image src="/photo.jpg" />produces a build that emits AVIF + WebP + JPEG variants at the configured widths into.patties/images/. - The rendered
<img>has asrcsetwith all variant URLs andloading="lazy"(unlesspriority). GET /_patties/image?src=/uploads/x.jpg&w=640&fmt=webp&sig=<correct>returns a 640px WebP variant cached at.patties/image-cache/<hash>.webp.- Same request with a bad
sigreturns 400. - Cache grows up to
cacheMaxBytesthen evicts oldest by atime. - On the edge adapter, the runtime endpoint route returns 404 and
<Image>for an unknown src renders the source URL directly with a console warning in dev.
Test plan
- Unit:
IMAGE_MANIFESTgeneration for a fixture with one image → assert N variants written + manifest entry shape. - Unit:
signImageUrl+verifyImageUrlround-trip; bad sig rejected. - Unit: cache eviction — fill past limit, assert oldest files removed.
- Integration:
<Image>SSR with manifest-known src → assert expected srcset shape. - Integration:
<Image>SSR with unknown src → assert signed endpoint URLs in srcset. - Integration:
GET /_patties/image?...correct flow → 200 + cached file on disk. - Adapter: edge build → endpoint absent + warning path verified.
Out of this spec
- Image upload handling (multipart form, S3 put, etc.) — separate concern.
- Sharp removal from the docs site or example apps — content task.
- Deploy-plugin image-url hook contract — covered in spec 12.