@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/LICENSE +21 -0
- package/README.md +18 -0
- package/dist/backend.d.ts +128 -0
- package/dist/backend.d.ts.map +1 -0
- package/dist/backend.js +170 -0
- package/dist/backend.js.map +1 -0
- package/dist/dimensions.d.ts +23 -0
- package/dist/dimensions.d.ts.map +1 -0
- package/dist/dimensions.js +113 -0
- package/dist/dimensions.js.map +1 -0
- package/dist/html.d.ts +21 -0
- package/dist/html.d.ts.map +1 -0
- package/dist/html.js +26 -0
- package/dist/html.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +10 -0
- package/dist/index.js.map +1 -0
- package/dist/resolve.d.ts +102 -0
- package/dist/resolve.d.ts.map +1 -0
- package/dist/resolve.js +102 -0
- package/dist/resolve.js.map +1 -0
- package/dist/server.d.ts +57 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +330 -0
- package/dist/server.js.map +1 -0
- package/dist/sign.d.ts +30 -0
- package/dist/sign.d.ts.map +1 -0
- package/dist/sign.js +161 -0
- package/dist/sign.js.map +1 -0
- package/package.json +56 -0
- package/src/backend.ts +273 -0
- package/src/dimensions.ts +129 -0
- package/src/html.ts +47 -0
- package/src/index.ts +26 -0
- package/src/resolve.ts +180 -0
- package/src/server.ts +397 -0
- package/src/sign.ts +187 -0
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
|
+
}
|