@nifrajs/image 0.1.0-alpha.1

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/src/html.ts ADDED
@@ -0,0 +1,47 @@
1
+ import type { ResolvedImage } from "./resolve.ts"
2
+
3
+ /** Plain lowercase HTML `<img>` attributes (`srcset`/`fetchpriority`, not React's camelCase). */
4
+ export interface HtmlImageAttrs {
5
+ readonly src: string
6
+ readonly width: number
7
+ readonly height: number
8
+ readonly alt: string
9
+ readonly loading: "lazy" | "eager"
10
+ readonly decoding: "async"
11
+ readonly srcset?: string
12
+ readonly sizes?: string
13
+ readonly fetchpriority?: "high"
14
+ }
15
+
16
+ /**
17
+ * Map a {@link ResolvedImage} to plain lowercase HTML `<img>` attribute names — `srcset` (not React's
18
+ * `srcSet`), `fetchpriority` (not `fetchPriority`) — dropping unset optionals. For the adapters that
19
+ * spread attributes straight onto a host element (Solid / Vue / Svelte / Preact); React consumes
20
+ * `ResolvedImage` directly because it requires the camelCased prop names.
21
+ */
22
+ export function toHtmlAttrs(resolved: ResolvedImage): HtmlImageAttrs {
23
+ // Build the required attrs, then add optionals only when present (exactOptionalPropertyTypes:
24
+ // never assign an explicit `undefined`). The result is safe to spread onto an <img>.
25
+ const attrs: {
26
+ src: string
27
+ width: number
28
+ height: number
29
+ alt: string
30
+ loading: "lazy" | "eager"
31
+ decoding: "async"
32
+ srcset?: string
33
+ sizes?: string
34
+ fetchpriority?: "high"
35
+ } = {
36
+ src: resolved.src,
37
+ width: resolved.width,
38
+ height: resolved.height,
39
+ alt: resolved.alt,
40
+ loading: resolved.loading,
41
+ decoding: resolved.decoding,
42
+ }
43
+ if (resolved.srcSet !== undefined) attrs.srcset = resolved.srcSet
44
+ if (resolved.sizes !== undefined) attrs.sizes = resolved.sizes
45
+ if (resolved.fetchPriority !== undefined) attrs.fetchpriority = resolved.fetchPriority
46
+ return attrs
47
+ }
package/src/index.ts ADDED
@@ -0,0 +1,26 @@
1
+ /**
2
+ * `@nifrajs/image` — image optimization for nifra. A CLS-safe responsive `<Image>` (per-adapter) built on
3
+ * the pure attribute builder `resolveImage`, pluggable CDN `ImageLoader`s (Cloudflare Images + an
4
+ * identity default), and dependency-free intrinsic-dimension reading. nifra bundles no image codec; the
5
+ * resize is delegated to the edge CDN via the loader. Per-adapter `<Image>` lives in the adapter packages.
6
+ */
7
+ export {
8
+ type ImageFormat,
9
+ type ImageInfo,
10
+ imageDimensions,
11
+ readImageDimensions,
12
+ } from "./dimensions.ts"
13
+ export { type HtmlImageAttrs, toHtmlAttrs } from "./html.ts"
14
+ export {
15
+ type CloudflareLoaderOptions,
16
+ cloudflareLoader,
17
+ type ImageLoader,
18
+ type ImageProps,
19
+ identityLoader,
20
+ type ResolvedImage,
21
+ resolveImage,
22
+ type SelfHostedLoaderOptions,
23
+ type SignImageUrlOptions,
24
+ selfHostedLoader,
25
+ signImageUrl,
26
+ } from "./resolve.ts"
package/src/resolve.ts ADDED
@@ -0,0 +1,180 @@
1
+ /**
2
+ * The pure attribute builder shared by every adapter `<Image>` — turns image props into CLS-safe,
3
+ * responsive `<img>` attributes (`src`/`srcSet`/`sizes`/`width`/`height`/`loading`/`decoding`/
4
+ * `fetchpriority`). The actual resize is delegated to an {@link ImageLoader} (a URL builder) — nifra
5
+ * bundles no codec; point the loader at your image CDN.
6
+ */
7
+
8
+ import { signImageParams } from "./sign.ts"
9
+
10
+ /** Builds a variant URL for `src` at a target pixel `width` (and optional `quality`). */
11
+ export type ImageLoader = (args: { src: string; width: number; quality?: number }) => string
12
+
13
+ /** Default loader: return the source unchanged (no transform). Use when there's no image CDN — you
14
+ * still get CLS-safe sizing + lazy loading, just no responsive variants. */
15
+ export const identityLoader: ImageLoader = ({ src }) => src
16
+
17
+ export interface CloudflareLoaderOptions {
18
+ /** Prefix for the source (e.g. an absolute origin) when `src` is a bare path. Default: none. */
19
+ readonly base?: string
20
+ }
21
+
22
+ /**
23
+ * Cloudflare Images loader — builds `/cdn-cgi/image/<options>/<source>` URLs that the Cloudflare edge
24
+ * resizes on the fly (also emits `format=auto` for webp/avif negotiation). Works on Cloudflare Pages /
25
+ * Workers with Images enabled.
26
+ */
27
+ export function cloudflareLoader(options: CloudflareLoaderOptions = {}): ImageLoader {
28
+ const base = options.base ?? ""
29
+ return ({ src, width, quality }) => {
30
+ const params = `format=auto,width=${width}${quality !== undefined ? `,quality=${quality}` : ""}`
31
+ const source = `${base}${src}`.replace(/^\//, "") // the leading slash is provided by the prefix below
32
+ return `/cdn-cgi/image/${params}/${source}`
33
+ }
34
+ }
35
+
36
+ export interface SelfHostedLoaderOptions {
37
+ /** Path/URL where `createImageHandler` (`@nifrajs/image/server`) is mounted, e.g. `"/_image"`. */
38
+ readonly endpoint: string
39
+ /**
40
+ * HMAC secret for **signed URLs**. When set, each URL gets a stable `&s=` signature and the handler
41
+ * must be configured with the SAME `signing.secret` — it then rejects any unsigned/forged `(src, w, q)`,
42
+ * shutting down resize-bombing. ⚠️ The signer holds the secret, so a loader created with it is
43
+ * **server-only** — inject it like a session secret (from `env`), never import this config into a
44
+ * route/client module. Signatures are stable (no expiry), so SSR-signed URLs hydrate + cache identically.
45
+ */
46
+ readonly secret?: string
47
+ }
48
+
49
+ /**
50
+ * Loader for nifra's **self-hosted** resize endpoint (`createImageHandler` from `@nifrajs/image/server`,
51
+ * backed by `Bun.Image`/sharp/WASM). Builds `<endpoint>?src=…&w=…[&q=…][&s=…]` (the endpoint negotiates
52
+ * the output format). Pure + dependency-free. For runtimes without a native codec, pair the endpoint
53
+ * with `wasmImageBackend`, or use the CDN `cloudflareLoader` instead.
54
+ */
55
+ export function selfHostedLoader(options: SelfHostedLoaderOptions): ImageLoader {
56
+ const { endpoint, secret } = options
57
+ return ({ src, width, quality }) => {
58
+ const w = String(width)
59
+ const q = quality !== undefined ? String(quality) : undefined
60
+ const params = new URLSearchParams({ src, w })
61
+ if (q !== undefined) params.set("q", q)
62
+ if (secret !== undefined) params.set("s", signImageParams(secret, { src, w, q }))
63
+ return `${endpoint}?${params.toString()}`
64
+ }
65
+ }
66
+
67
+ export interface SignImageUrlOptions {
68
+ /** HMAC secret — must match the handler's `signing.secret`. */
69
+ readonly secret: string
70
+ /** Seconds until the URL expires (adds `&exp=`). Omit for a stable, cacheable-forever signed URL. */
71
+ readonly expiresIn?: number
72
+ }
73
+
74
+ /**
75
+ * Mint a **signed** self-hosted image URL on the server — for cases the (stable) `selfHostedLoader`
76
+ * doesn't cover, chiefly **time-limited** access (`expiresIn`) to private images. Server-only (it holds
77
+ * the secret). Pair with a passthrough loader, or use the signed string as a plain `src`.
78
+ *
79
+ * ```ts
80
+ * const url = signImageUrl("/_image", { src: "/private/a.jpg", width: 800 }, { secret, expiresIn: 300 })
81
+ * ```
82
+ */
83
+ export function signImageUrl(
84
+ endpoint: string,
85
+ image: { src: string; width: number; quality?: number },
86
+ options: SignImageUrlOptions,
87
+ ): string {
88
+ const w = String(image.width)
89
+ const q = image.quality !== undefined ? String(image.quality) : undefined
90
+ const exp =
91
+ options.expiresIn !== undefined
92
+ ? String(Math.floor(Date.now() / 1000) + options.expiresIn)
93
+ : undefined
94
+ const params = new URLSearchParams({ src: image.src, w })
95
+ if (q !== undefined) params.set("q", q)
96
+ if (exp !== undefined) params.set("exp", exp)
97
+ params.set("s", signImageParams(options.secret, { src: image.src, w, q, exp }))
98
+ return `${endpoint}?${params.toString()}`
99
+ }
100
+
101
+ export interface ImageProps {
102
+ readonly src: string
103
+ /** Intrinsic width (px) — **required**, reserves layout space (no CLS). */
104
+ readonly width: number
105
+ /** Intrinsic height (px) — **required** (no CLS). */
106
+ readonly height: number
107
+ /** Alt text — **required** for accessibility (use `alt=""` for decorative images). */
108
+ readonly alt: string
109
+ /** `sizes` attribute for responsive selection (e.g. `"(max-width: 600px) 100vw, 600px"`). */
110
+ readonly sizes?: string
111
+ /** Widths to generate `srcSet` entries for. Default: `[width, width*2]` (1×/2× retina). */
112
+ readonly widths?: readonly number[]
113
+ /** Quality passed to the loader. */
114
+ readonly quality?: number
115
+ /** `lazy` (default) or `eager`. `priority` overrides this to eager. */
116
+ readonly loading?: "lazy" | "eager"
117
+ /** Mark the LCP image: `loading="eager"` + `fetchpriority="high"`. */
118
+ readonly priority?: boolean
119
+ }
120
+
121
+ export interface ResolvedImage {
122
+ readonly src: string
123
+ readonly srcSet?: string
124
+ readonly sizes?: string
125
+ readonly width: number
126
+ readonly height: number
127
+ readonly alt: string
128
+ readonly loading: "lazy" | "eager"
129
+ readonly decoding: "async"
130
+ readonly fetchPriority?: "high"
131
+ }
132
+
133
+ /**
134
+ * Resolve {@link ImageProps} + an {@link ImageLoader} into `<img>` attributes. CLS-safe (`width`/
135
+ * `height` required + > 0, else a dev error), lazy + async-decoding by default, with a responsive
136
+ * `srcSet` built from `widths` via the loader. If every width produces the same URL (e.g.
137
+ * {@link identityLoader}), `srcSet` is omitted (it'd be redundant).
138
+ */
139
+ export function resolveImage(
140
+ props: ImageProps,
141
+ loader: ImageLoader = identityLoader,
142
+ ): ResolvedImage {
143
+ if (
144
+ !Number.isFinite(props.width) ||
145
+ props.width <= 0 ||
146
+ !Number.isFinite(props.height) ||
147
+ props.height <= 0
148
+ ) {
149
+ throw new Error(
150
+ `[nifra/image] <Image> requires positive width + height (got ${props.width}×${props.height}) — they reserve layout space to prevent CLS.`,
151
+ )
152
+ }
153
+ const eager = props.priority === true || props.loading === "eager"
154
+ const widths = [...new Set(props.widths ?? [props.width, props.width * 2])].sort((a, b) => a - b)
155
+
156
+ // Build the loader args, omitting `quality` when unset (exactOptionalPropertyTypes: no `undefined`).
157
+ const at = (w: number): string =>
158
+ loader(
159
+ props.quality === undefined
160
+ ? { src: props.src, width: w }
161
+ : { src: props.src, width: w, quality: props.quality },
162
+ )
163
+ const entries = widths.map((w) => ({ url: at(w), w }))
164
+ const allSame = entries.every((e) => e.url === entries[0]?.url)
165
+ const srcSet = allSame ? undefined : entries.map((e) => `${e.url} ${e.w}w`).join(", ")
166
+
167
+ // `src` is the 1× (intrinsic-width) variant — the fallback for browsers ignoring srcSet.
168
+ const base: ResolvedImage = {
169
+ src: at(props.width),
170
+ width: props.width,
171
+ height: props.height,
172
+ alt: props.alt,
173
+ loading: eager ? "eager" : "lazy",
174
+ decoding: "async",
175
+ ...(srcSet !== undefined ? { srcSet } : {}),
176
+ ...(props.sizes !== undefined ? { sizes: props.sizes } : {}),
177
+ ...(props.priority === true ? { fetchPriority: "high" as const } : {}),
178
+ }
179
+ return base
180
+ }
package/src/server.ts ADDED
@@ -0,0 +1,397 @@
1
+ /**
2
+ * `@nifrajs/image/server` — nifra's **self-hosted** image resize endpoint. `createImageHandler` returns a
3
+ * `(req: Request) => Promise<Response>` you mount at the path your {@link selfHostedLoader} points to
4
+ * (e.g. `"/_image"`). It validates the query, safely resolves the source (local file under a `root`, or
5
+ * a remote URL on an `allowedOrigins` allowlist — both **fail-closed** against SSRF), then decodes →
6
+ * resizes (never upscaling) → re-encodes via a pluggable {@link ImageBackend}. The default
7
+ * {@link bunImageBackend} uses `Bun.Image` (libjpeg-turbo / libspng / libwebp, off-thread).
8
+ *
9
+ * This subpath touches the filesystem and a native codec, so it is for **Node/Bun servers**, not the
10
+ * edge. On Workers / Vercel-Edge / Deno-Deploy there is no native codec — use the CDN `cloudflareLoader`
11
+ * from `@nifrajs/image` instead. The dependency-free core (`@nifrajs/image`) never imports this module.
12
+ */
13
+
14
+ import { readFile, realpath, stat } from "node:fs/promises"
15
+ import { resolve as resolvePath, sep } from "node:path"
16
+
17
+ import {
18
+ bunImageBackend,
19
+ type ImageBackend,
20
+ ImageProcessingError,
21
+ type OutputFormat,
22
+ } from "./backend.ts"
23
+
24
+ import { verifyImageParams } from "./sign.ts"
25
+
26
+ // The codec seam + every official backend (Bun/sharp/WASM) live in the edge-safe `./backend.ts`
27
+ // (`@nifrajs/image/backends`), importable WITHOUT this module's `node:fs` — so a WASM backend can ship to
28
+ // the edge. Re-export them so `@nifrajs/image/server` stays the single import for Node servers.
29
+ export * from "./backend.ts"
30
+
31
+ export interface ImageHandlerOptions {
32
+ /** Codec backend. Default: {@link bunImageBackend} (requires the Bun runtime). */
33
+ readonly backend?: ImageBackend
34
+ /** Absolute directory that local (path) sources resolve under. Path-traversal- and symlink-guarded.
35
+ * Omit to **disable local sources** (every path source → 403). */
36
+ readonly root?: string
37
+ /** Exact origins (`https://cdn.example`) allowed for remote sources. Omitted/empty ⇒ **no remote
38
+ * sources** (fail-closed against SSRF). Only `http:`/`https:` URLs are ever considered. */
39
+ readonly allowedOrigins?: readonly string[]
40
+ /** Max bytes read from a source before rejecting (413). Default 20 MiB. */
41
+ readonly maxSourceBytes?: number
42
+ /** Max source pixels (w×h) before rejecting (413) — decompression-bomb guard. Default 40 MP. */
43
+ readonly maxSourcePixels?: number
44
+ /** Hard cap on the requested width; larger `?w` is clamped down. Default 3840. */
45
+ readonly maxWidth?: number
46
+ /** Max concurrent transforms (codec work is CPU/memory-heavy). Excess requests queue. Default 4. */
47
+ readonly concurrency?: number
48
+ /** `Cache-Control: public, max-age=<n>, immutable` seconds. Default 1 year. */
49
+ readonly cacheMaxAge?: number
50
+ /** Quality used when `?q` is absent. Default 75. */
51
+ readonly defaultQuality?: number
52
+ /** Remote-fetch timeout (ms). Default 10 000. */
53
+ readonly fetchTimeoutMs?: number
54
+ /** `fetch` implementation for remote sources (injectable for custom timeouts/proxy and for tests).
55
+ * Default: the global `fetch`. */
56
+ readonly fetch?: typeof fetch
57
+ /** Require **signed URLs**: every request must carry a valid `&s=` HMAC over `(src, w, q[, exp])` or
58
+ * it's rejected with `403`. Use the SAME `secret` as your `selfHostedLoader`/`signImageUrl`. This
59
+ * locks the endpoint to URLs your app minted — the defense against resize-bombing. */
60
+ readonly signing?: { readonly secret: string }
61
+ }
62
+
63
+ const MAX_SRC_LEN = 2048
64
+
65
+ interface ResolvedConfig {
66
+ readonly backend: ImageBackend
67
+ readonly root: string | null
68
+ readonly allowedOrigins: ReadonlySet<string>
69
+ readonly maxSourceBytes: number
70
+ readonly maxSourcePixels: number
71
+ readonly maxWidth: number
72
+ readonly cacheMaxAge: number
73
+ readonly defaultQuality: number
74
+ readonly fetchTimeoutMs: number
75
+ readonly fetchImpl: typeof fetch
76
+ readonly signing: { readonly secret: string } | null
77
+ readonly acquire: () => Promise<void>
78
+ readonly release: () => void
79
+ }
80
+
81
+ /**
82
+ * Build the resize request handler. Mount its return value at the `selfHostedLoader` endpoint:
83
+ *
84
+ * ```ts
85
+ * const image = createImageHandler({ root: "./public", allowedOrigins: ["https://cdn.example"] })
86
+ * // inside your router: if (url.pathname === "/_image") return image(req)
87
+ * ```
88
+ */
89
+ export function createImageHandler(
90
+ options: ImageHandlerOptions = {},
91
+ ): (req: Request) => Promise<Response> {
92
+ const cfg = resolveConfig(options)
93
+ return (req: Request) => handle(req, cfg)
94
+ }
95
+
96
+ function resolveConfig(options: ImageHandlerOptions): ResolvedConfig {
97
+ const concurrency = Math.max(1, options.concurrency ?? 4)
98
+ const sem = createSemaphore(concurrency)
99
+ return {
100
+ backend: options.backend ?? bunImageBackend(),
101
+ root: options.root !== undefined ? resolvePath(options.root) : null,
102
+ allowedOrigins: new Set(options.allowedOrigins ?? []),
103
+ maxSourceBytes: options.maxSourceBytes ?? 20 * 1024 * 1024,
104
+ maxSourcePixels: options.maxSourcePixels ?? 40_000_000,
105
+ maxWidth: options.maxWidth ?? 3840,
106
+ cacheMaxAge: options.cacheMaxAge ?? 31_536_000,
107
+ defaultQuality: options.defaultQuality ?? 75,
108
+ fetchTimeoutMs: options.fetchTimeoutMs ?? 10_000,
109
+ fetchImpl: options.fetch ?? globalThis.fetch,
110
+ signing: options.signing ?? null,
111
+ acquire: sem.acquire,
112
+ release: sem.release,
113
+ }
114
+ }
115
+
116
+ async function handle(req: Request, cfg: ResolvedConfig): Promise<Response> {
117
+ // 1. Method — only safe reads. (HEAD shares the GET path; the body is stripped at the end.)
118
+ if (req.method !== "GET" && req.method !== "HEAD") {
119
+ return errorResponse(405, "method_not_allowed", { Allow: "GET, HEAD" })
120
+ }
121
+
122
+ // 2. Validate the query at the trust boundary (strict scalar parsing — no Number() coercion).
123
+ const url = new URL(req.url)
124
+ const src = url.searchParams.get("src")
125
+ if (src === null || src.length === 0 || src.length > MAX_SRC_LEN) {
126
+ return errorResponse(400, "invalid_src")
127
+ }
128
+ const requestedWidth = parsePositiveInt(url.searchParams.get("w"))
129
+ if (requestedWidth === null) return errorResponse(400, "invalid_width")
130
+ const qParam = url.searchParams.get("q")
131
+ let quality = cfg.defaultQuality
132
+ if (qParam !== null) {
133
+ const q = parsePositiveInt(qParam)
134
+ if (q === null) return errorResponse(400, "invalid_quality")
135
+ quality = Math.min(100, q)
136
+ }
137
+ const width = Math.min(cfg.maxWidth, requestedWidth)
138
+ const wantsWebp = (req.headers.get("accept") ?? "").includes("image/webp")
139
+
140
+ // 2b. Signed-URL enforcement (when configured): reject any request we didn't mint. Verifies the
141
+ // `&s=` HMAC over the raw (src, w, q[, exp]) — exactly what the loader/`signImageUrl` signed —
142
+ // and the expiry. Done before the ETag/fetch so unsigned/forged requests cost nothing.
143
+ if (cfg.signing !== null) {
144
+ const sig = url.searchParams.get("s")
145
+ const ok =
146
+ sig !== null &&
147
+ verifyImageParams(
148
+ cfg.signing.secret,
149
+ {
150
+ src,
151
+ w: url.searchParams.get("w") ?? "",
152
+ q: qParam ?? undefined,
153
+ exp: url.searchParams.get("exp") ?? undefined,
154
+ },
155
+ sig,
156
+ Math.floor(Date.now() / 1000),
157
+ )
158
+ if (!ok) return errorResponse(403, "invalid_signature")
159
+ }
160
+
161
+ // 3. Strong validator from the request-deterministic inputs, computed BEFORE any fetch/decode so a
162
+ // conditional request short-circuits all expensive work. The clamped width is a pure function of
163
+ // (src, requestedWidth), so (src, requestedWidth, quality, wantsWebp) fully keys the response.
164
+ const etag = `"${fnv1a(`${src}|${requestedWidth}|${quality}|${wantsWebp ? 1 : 0}`)}"`
165
+ const cacheHeaders: Record<string, string> = {
166
+ "Cache-Control": `public, max-age=${cfg.cacheMaxAge}, immutable`,
167
+ ETag: etag,
168
+ Vary: "Accept",
169
+ }
170
+ if (ifNoneMatch(req, etag)) {
171
+ return new Response(null, { status: 304, headers: cacheHeaders })
172
+ }
173
+
174
+ // 4. Resolve + read the source under the SSRF policy (fail-closed: unknown source kind → 403/400).
175
+ const source = await readSource(src, cfg)
176
+ if (!source.ok) return errorResponse(source.status, source.code)
177
+
178
+ // 5. Probe → enforce the portable pixel cap → clamp to intrinsic (never upscale) → transform.
179
+ // The codec work is bounded by the concurrency semaphore.
180
+ await cfg.acquire()
181
+ try {
182
+ const probe = await cfg.backend.probe(source.bytes)
183
+ if (probe.width * probe.height > cfg.maxSourcePixels) {
184
+ return errorResponse(413, "source_too_large")
185
+ }
186
+ const targetWidth = Math.max(1, Math.min(width, probe.width))
187
+ const format = negotiateFormat(probe.format, wantsWebp)
188
+ const out = await cfg.backend.transform({
189
+ bytes: source.bytes,
190
+ width: targetWidth,
191
+ quality,
192
+ format,
193
+ })
194
+
195
+ const headers = new Headers(cacheHeaders)
196
+ headers.set("Content-Type", out.contentType)
197
+ headers.set("Content-Length", String(out.bytes.byteLength))
198
+ headers.set("X-Content-Type-Options", "nosniff") // never let a client sniff the re-encoded bytes
199
+ // HEAD: identical headers, no body.
200
+ if (req.method === "HEAD") return new Response(null, { status: 200, headers })
201
+ return new Response(out.bytes, { status: 200, headers })
202
+ } catch (err) {
203
+ if (err instanceof ImageProcessingError) {
204
+ if (err.kind === "too_large") return errorResponse(413, "source_too_large")
205
+ return errorResponse(415, "unsupported_media_type") // decode | unsupported
206
+ }
207
+ // Unexpected — never leak internals to the client.
208
+ return errorResponse(500, "internal_error")
209
+ } finally {
210
+ cfg.release()
211
+ }
212
+ }
213
+
214
+ type SourceResult =
215
+ | { readonly ok: true; readonly bytes: Uint8Array }
216
+ | { readonly ok: false; readonly status: number; readonly code: string }
217
+
218
+ /**
219
+ * Resolve `src` to bytes under the SSRF policy. A value that parses as an absolute URL is "remote" and
220
+ * must be `http(s)` **and** on the `allowedOrigins` allowlist; anything else is a local path resolved
221
+ * under `root` with traversal + symlink containment checks. Both branches fail closed.
222
+ */
223
+ async function readSource(src: string, cfg: ResolvedConfig): Promise<SourceResult> {
224
+ const asUrl = tryParseUrl(src)
225
+ if (asUrl !== null) {
226
+ if (asUrl.protocol !== "http:" && asUrl.protocol !== "https:") {
227
+ return { ok: false, status: 400, code: "unsupported_scheme" }
228
+ }
229
+ if (!cfg.allowedOrigins.has(asUrl.origin)) {
230
+ return { ok: false, status: 403, code: "source_not_allowed" }
231
+ }
232
+ return fetchRemote(asUrl, cfg)
233
+ }
234
+ return readLocal(src, cfg)
235
+ }
236
+
237
+ async function fetchRemote(url: URL, cfg: ResolvedConfig): Promise<SourceResult> {
238
+ let res: Response
239
+ try {
240
+ res = await cfg.fetchImpl(url, {
241
+ redirect: "error", // a redirect could bounce to a disallowed origin — refuse it.
242
+ signal: AbortSignal.timeout(cfg.fetchTimeoutMs),
243
+ headers: { Accept: "image/*" },
244
+ })
245
+ } catch (err) {
246
+ const name = err instanceof Error ? err.name : ""
247
+ return name === "TimeoutError"
248
+ ? { ok: false, status: 504, code: "upstream_timeout" }
249
+ : { ok: false, status: 502, code: "upstream_unreachable" }
250
+ }
251
+ if (!res.ok) return { ok: false, status: 502, code: "upstream_error" }
252
+ const bytes = await readBoundedBytes(res, cfg.maxSourceBytes)
253
+ if (bytes === null) return { ok: false, status: 413, code: "source_too_large" }
254
+ return { ok: true, bytes }
255
+ }
256
+
257
+ async function readLocal(src: string, cfg: ResolvedConfig): Promise<SourceResult> {
258
+ if (cfg.root === null) return { ok: false, status: 403, code: "source_not_allowed" }
259
+ // `src` is already percent-decoded by URLSearchParams — do NOT decode again (double-decode bypass).
260
+ if (src.includes("\0")) return { ok: false, status: 400, code: "invalid_src" }
261
+ // Strip leading slashes so a site-absolute ("/hero.jpg") or protocol-relative ("//evil") path is
262
+ // treated as root-relative; `resolve` then collapses any "../" and the containment check rejects it.
263
+ const rel = src.replace(/^\/+/, "")
264
+ const resolved = resolvePath(cfg.root, rel)
265
+ if (resolved !== cfg.root && !resolved.startsWith(cfg.root + sep)) {
266
+ return { ok: false, status: 403, code: "source_not_allowed" }
267
+ }
268
+ let info: Awaited<ReturnType<typeof stat>>
269
+ try {
270
+ info = await stat(resolved)
271
+ } catch {
272
+ return { ok: false, status: 404, code: "source_not_found" }
273
+ }
274
+ if (!info.isFile()) return { ok: false, status: 404, code: "source_not_found" }
275
+ if (info.size > cfg.maxSourceBytes) return { ok: false, status: 413, code: "source_too_large" }
276
+ // Defense-in-depth: a symlink inside root could point outside it — re-check the real path.
277
+ try {
278
+ const real = await realpath(resolved)
279
+ const realRoot = await realpath(cfg.root)
280
+ if (real !== realRoot && !real.startsWith(realRoot + sep)) {
281
+ return { ok: false, status: 403, code: "source_not_allowed" }
282
+ }
283
+ } catch {
284
+ return { ok: false, status: 404, code: "source_not_found" }
285
+ }
286
+ return { ok: true, bytes: new Uint8Array(await readFile(resolved)) }
287
+ }
288
+
289
+ /** Read a response body, aborting (→ null) once `limit` bytes are exceeded. Trusts neither the
290
+ * `Content-Length` header (checked as a fast reject) nor an absent one — the running total is the gate. */
291
+ async function readBoundedBytes(res: Response, limit: number): Promise<Uint8Array | null> {
292
+ const declared = res.headers.get("content-length")
293
+ if (declared !== null && /^\d+$/.test(declared) && Number(declared) > limit) return null
294
+ const reader = res.body?.getReader()
295
+ if (reader === undefined) {
296
+ const buf = new Uint8Array(await res.arrayBuffer())
297
+ return buf.byteLength > limit ? null : buf
298
+ }
299
+ const chunks: Uint8Array[] = []
300
+ let total = 0
301
+ for (;;) {
302
+ const { done, value } = await reader.read()
303
+ if (done) break
304
+ total += value.byteLength
305
+ if (total > limit) {
306
+ await reader.cancel()
307
+ return null
308
+ }
309
+ chunks.push(value)
310
+ }
311
+ const out = new Uint8Array(total)
312
+ let offset = 0
313
+ for (const c of chunks) {
314
+ out.set(c, offset)
315
+ offset += c.byteLength
316
+ }
317
+ return out
318
+ }
319
+
320
+ /** Prefer WebP when the client advertises it (supports alpha + best compression). Otherwise preserve a
321
+ * JPEG source as JPEG; everything else falls back to PNG (lossless, keeps any alpha). `Vary: Accept`
322
+ * keeps the WebP / non-WebP variants cached separately. */
323
+ function negotiateFormat(sourceFormat: string, wantsWebp: boolean): OutputFormat {
324
+ if (wantsWebp) return "webp"
325
+ if (sourceFormat === "jpeg" || sourceFormat === "jpg") return "jpeg"
326
+ return "png"
327
+ }
328
+
329
+ /** Strict positive-integer parse: canonical decimal digits only (rejects signs, decimals, `1e3`, `0x`,
330
+ * whitespace, empty). Returns null on any non-canonical input. */
331
+ function parsePositiveInt(raw: string | null): number | null {
332
+ if (raw === null || !/^\d+$/.test(raw)) return null
333
+ const n = Number(raw)
334
+ return Number.isSafeInteger(n) && n >= 1 ? n : null
335
+ }
336
+
337
+ function tryParseUrl(src: string): URL | null {
338
+ try {
339
+ return new URL(src)
340
+ } catch {
341
+ return null
342
+ }
343
+ }
344
+
345
+ function ifNoneMatch(req: Request, etag: string): boolean {
346
+ const header = req.headers.get("if-none-match")
347
+ if (header === null) return false
348
+ if (header.trim() === "*") return true
349
+ return header.split(",").some((t) => t.trim() === etag)
350
+ }
351
+
352
+ /** 32-bit FNV-1a over a short key string → hex. Used only as a cache validator (ETag), not security. */
353
+ function fnv1a(input: string): string {
354
+ let h = 0x811c9dc5
355
+ for (let i = 0; i < input.length; i++) {
356
+ h ^= input.charCodeAt(i)
357
+ h = Math.imul(h, 0x01000193)
358
+ }
359
+ return (h >>> 0).toString(16)
360
+ }
361
+
362
+ function errorResponse(
363
+ status: number,
364
+ code: string,
365
+ extraHeaders: Record<string, string> = {},
366
+ ): Response {
367
+ return new Response(JSON.stringify({ error: code }), {
368
+ status,
369
+ headers: { "Content-Type": "application/json; charset=utf-8", ...extraHeaders },
370
+ })
371
+ }
372
+
373
+ /**
374
+ * Async counting semaphore with direct slot hand-off — a released slot is passed straight to the next
375
+ * waiter (the active count is never transiently decremented), so a concurrent `acquire()` can't slip
376
+ * past the limit in the microtask gap. Bounds concurrent codec work.
377
+ */
378
+ function createSemaphore(max: number): { acquire: () => Promise<void>; release: () => void } {
379
+ let active = 0
380
+ const waiters: Array<() => void> = []
381
+ const acquire = async (): Promise<void> => {
382
+ if (active < max) {
383
+ active++
384
+ return
385
+ }
386
+ await new Promise<void>((res) => waiters.push(res)) // slot handed over by release(), active unchanged
387
+ }
388
+ const release = (): void => {
389
+ const next = waiters.shift()
390
+ if (next !== undefined) {
391
+ next() // hand the slot to the waiter without touching `active`
392
+ } else {
393
+ active--
394
+ }
395
+ }
396
+ return { acquire, release }
397
+ }