@nifrajs/image 2.2.0 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/dist/backend.d.ts +13 -13
- package/dist/backend.js +8 -8
- package/dist/dimensions.d.ts +2 -2
- package/dist/dimensions.js +2 -2
- package/dist/html.d.ts +2 -2
- package/dist/html.js +2 -2
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/resolve.d.ts +11 -11
- package/dist/resolve.js +7 -7
- package/dist/server.d.ts +5 -5
- package/dist/server.js +13 -13
- package/dist/sign.d.ts +2 -2
- package/dist/sign.js +5 -5
- package/package.json +2 -2
- package/src/backend.ts +14 -14
- package/src/dimensions.ts +3 -3
- package/src/html.ts +2 -2
- package/src/index.ts +1 -1
- package/src/resolve.ts +13 -13
- package/src/server.ts +15 -15
- package/src/sign.ts +5 -5
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# @nifrajs/image
|
|
2
2
|
|
|
3
|
-
Image optimization for nifra
|
|
3
|
+
Image optimization for nifra - a CLS-safe responsive <Image> + pluggable loaders (CDN or self-hosted) + pure-JS dimension reading. Zero-dependency core; optional Bun.Image-backed resize endpoint at @nifrajs/image/server.
|
|
4
4
|
|
|
5
|
-
Part of the **[nifra](https://nifra.dev)** full-stack TypeScript framework
|
|
5
|
+
Part of the **[nifra](https://nifra.dev)** full-stack TypeScript framework - one core, five UI libraries, every runtime. Scaffold a new app with `bun create nifra`.
|
|
6
6
|
|
|
7
7
|
## Install
|
|
8
8
|
|
|
@@ -19,7 +19,7 @@ MIT
|
|
|
19
19
|
|
|
20
20
|
## For AI agents
|
|
21
21
|
|
|
22
|
-
Start with [`LLM.md`](./LLM.md)
|
|
22
|
+
Start with [`LLM.md`](./LLM.md) - this package's contract card (the exports you call + its footguns),
|
|
23
23
|
one cheap read instead of the whole corpus. For the wider framework: the repo's
|
|
24
24
|
[`AGENTS.md`](../../AGENTS.md) is the copy-paste quick reference, and
|
|
25
25
|
[`llms-full.txt`](../../llms-full.txt) is the full machine-readable corpus. Run `nifra check` as the
|
package/dist/backend.d.ts
CHANGED
|
@@ -1,21 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `@nifrajs/image/backends`
|
|
2
|
+
* `@nifrajs/image/backends` - the codec seam + the official {@link ImageBackend} implementations, kept
|
|
3
3
|
* **dependency-free and edge-safe** (no `node:` imports), so the WASM backend can run on Workers /
|
|
4
|
-
* Vercel-Edge / Deno-Deploy. `createImageHandler` (in `@nifrajs/image/server`, Node-only
|
|
4
|
+
* Vercel-Edge / Deno-Deploy. `createImageHandler` (in `@nifrajs/image/server`, Node-only - it touches the
|
|
5
5
|
* filesystem) consumes this seam and owns all request-level security; a backend only
|
|
6
6
|
* decodes/resizes/encodes and translates codec failures into {@link ImageProcessingError}.
|
|
7
7
|
*
|
|
8
8
|
* Pick a backend for your runtime:
|
|
9
|
-
* - {@link bunImageBackend}
|
|
10
|
-
* - {@link sharpImageBackend}
|
|
11
|
-
* - {@link wasmImageBackend}
|
|
9
|
+
* - {@link bunImageBackend} - `Bun.Image` (libjpeg-turbo / libspng / libwebp). Bun servers.
|
|
10
|
+
* - {@link sharpImageBackend} - pass your `sharp` import. Node servers.
|
|
11
|
+
* - {@link wasmImageBackend} - pass WASM codecs (e.g. jSquash). Any runtime, including the edge.
|
|
12
12
|
*/
|
|
13
|
-
/** Output formats nifra's endpoint can emit. AVIF is intentionally excluded
|
|
13
|
+
/** Output formats nifra's endpoint can emit. AVIF is intentionally excluded - `Bun.Image` reports
|
|
14
14
|
* `ERR_IMAGE_FORMAT_UNSUPPORTED` for AVIF encode on common platforms, so offering it would 500. */
|
|
15
15
|
export type OutputFormat = "webp" | "jpeg" | "png";
|
|
16
16
|
export declare const CONTENT_TYPE: Record<OutputFormat, string>;
|
|
17
17
|
/** Header-only probe of a source image: intrinsic dimensions + decoded format. Must be cheap (no full
|
|
18
|
-
* decode)
|
|
18
|
+
* decode) - it gates the decompression-bomb and no-upscale checks before the expensive resize. */
|
|
19
19
|
export interface ImageProbe {
|
|
20
20
|
readonly width: number;
|
|
21
21
|
readonly height: number;
|
|
@@ -24,7 +24,7 @@ export interface ImageProbe {
|
|
|
24
24
|
}
|
|
25
25
|
export interface ResizeInput {
|
|
26
26
|
readonly bytes: Uint8Array;
|
|
27
|
-
/** Target width in px
|
|
27
|
+
/** Target width in px - already clamped to `[1, maxWidth]` **and** to the source's intrinsic width
|
|
28
28
|
* (the handler never asks a backend to upscale). Aspect ratio is preserved. */
|
|
29
29
|
readonly width: number;
|
|
30
30
|
/** Encoder quality `1..100` (ignored for lossless `png`). */
|
|
@@ -59,7 +59,7 @@ export declare class ImageProcessingError extends Error {
|
|
|
59
59
|
*/
|
|
60
60
|
export declare function bunImageBackend(): ImageBackend;
|
|
61
61
|
/** The slice of a [sharp](https://sharp.pixelplumbing.com) instance this backend uses. Declared
|
|
62
|
-
* structurally so `@nifrajs/image` has no dependency on sharp
|
|
62
|
+
* structurally so `@nifrajs/image` has no dependency on sharp - pass your own `sharp` import. */
|
|
63
63
|
export type SharpLike = (input: Uint8Array) => SharpInstance;
|
|
64
64
|
interface SharpInstance {
|
|
65
65
|
metadata(): Promise<{
|
|
@@ -82,7 +82,7 @@ interface SharpInstance {
|
|
|
82
82
|
}
|
|
83
83
|
/**
|
|
84
84
|
* {@link ImageBackend} backed by [sharp](https://sharp.pixelplumbing.com) (libvips) for Node servers.
|
|
85
|
-
* Pass your `sharp` import
|
|
85
|
+
* Pass your `sharp` import - `@nifrajs/image` never imports it, so it stays dependency-free and you control
|
|
86
86
|
* the version:
|
|
87
87
|
*
|
|
88
88
|
* ```ts
|
|
@@ -91,7 +91,7 @@ interface SharpInstance {
|
|
|
91
91
|
* ```
|
|
92
92
|
*/
|
|
93
93
|
export declare function sharpImageBackend(sharp: SharpLike): ImageBackend;
|
|
94
|
-
/** A decoded image: RGBA pixels + dimensions
|
|
94
|
+
/** A decoded image: RGBA pixels + dimensions - the lingua franca of WASM codecs (jSquash, Photon, …). */
|
|
95
95
|
export interface DecodedImage {
|
|
96
96
|
/** RGBA bytes, length `width * height * 4`. */
|
|
97
97
|
readonly data: Uint8Array;
|
|
@@ -99,7 +99,7 @@ export interface DecodedImage {
|
|
|
99
99
|
readonly height: number;
|
|
100
100
|
}
|
|
101
101
|
/**
|
|
102
|
-
* Pluggable WASM codec set
|
|
102
|
+
* Pluggable WASM codec set - decode/resize/encode. Declared structurally so `@nifrajs/image` depends on no
|
|
103
103
|
* WASM library; wire your own (jSquash is the common pure-WASM, edge-safe choice). The handler probes
|
|
104
104
|
* dimensions from the source header (bomb-safe), so `decode` runs only inside `transform`.
|
|
105
105
|
*/
|
|
@@ -112,7 +112,7 @@ export interface WasmImageCodecs {
|
|
|
112
112
|
encode(image: DecodedImage, format: OutputFormat, quality: number): Promise<Uint8Array> | Uint8Array;
|
|
113
113
|
}
|
|
114
114
|
/**
|
|
115
|
-
* {@link ImageBackend} backed by injected WASM codecs
|
|
115
|
+
* {@link ImageBackend} backed by injected WASM codecs - the only backend that runs on the **edge**
|
|
116
116
|
* (Workers / Vercel-Edge / Deno-Deploy), where neither `Bun.Image` nor sharp exists. `probe` reads the
|
|
117
117
|
* source header via nifra's dependency-free reader (so decompression bombs are rejected before any
|
|
118
118
|
* decode); `transform` decodes → resizes (aspect-preserving) → encodes through your codecs.
|
package/dist/backend.js
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `@nifrajs/image/backends`
|
|
2
|
+
* `@nifrajs/image/backends` - the codec seam + the official {@link ImageBackend} implementations, kept
|
|
3
3
|
* **dependency-free and edge-safe** (no `node:` imports), so the WASM backend can run on Workers /
|
|
4
|
-
* Vercel-Edge / Deno-Deploy. `createImageHandler` (in `@nifrajs/image/server`, Node-only
|
|
4
|
+
* Vercel-Edge / Deno-Deploy. `createImageHandler` (in `@nifrajs/image/server`, Node-only - it touches the
|
|
5
5
|
* filesystem) consumes this seam and owns all request-level security; a backend only
|
|
6
6
|
* decodes/resizes/encodes and translates codec failures into {@link ImageProcessingError}.
|
|
7
7
|
*
|
|
8
8
|
* Pick a backend for your runtime:
|
|
9
|
-
* - {@link bunImageBackend}
|
|
10
|
-
* - {@link sharpImageBackend}
|
|
11
|
-
* - {@link wasmImageBackend}
|
|
9
|
+
* - {@link bunImageBackend} - `Bun.Image` (libjpeg-turbo / libspng / libwebp). Bun servers.
|
|
10
|
+
* - {@link sharpImageBackend} - pass your `sharp` import. Node servers.
|
|
11
|
+
* - {@link wasmImageBackend} - pass WASM codecs (e.g. jSquash). Any runtime, including the edge.
|
|
12
12
|
*/
|
|
13
13
|
import { imageDimensions } from "./dimensions.js";
|
|
14
14
|
export const CONTENT_TYPE = {
|
|
@@ -77,7 +77,7 @@ function toBunError(err) {
|
|
|
77
77
|
}
|
|
78
78
|
/**
|
|
79
79
|
* {@link ImageBackend} backed by [sharp](https://sharp.pixelplumbing.com) (libvips) for Node servers.
|
|
80
|
-
* Pass your `sharp` import
|
|
80
|
+
* Pass your `sharp` import - `@nifrajs/image` never imports it, so it stays dependency-free and you control
|
|
81
81
|
* the version:
|
|
82
82
|
*
|
|
83
83
|
* ```ts
|
|
@@ -130,7 +130,7 @@ function toSharpError(err) {
|
|
|
130
130
|
return new ImageProcessingError("decode", "sharp failed to process the image");
|
|
131
131
|
}
|
|
132
132
|
/**
|
|
133
|
-
* {@link ImageBackend} backed by injected WASM codecs
|
|
133
|
+
* {@link ImageBackend} backed by injected WASM codecs - the only backend that runs on the **edge**
|
|
134
134
|
* (Workers / Vercel-Edge / Deno-Deploy), where neither `Bun.Image` nor sharp exists. `probe` reads the
|
|
135
135
|
* source header via nifra's dependency-free reader (so decompression bombs are rejected before any
|
|
136
136
|
* decode); `transform` decodes → resizes (aspect-preserving) → encodes through your codecs.
|
|
@@ -144,7 +144,7 @@ function toSharpError(err) {
|
|
|
144
144
|
export function wasmImageBackend(codecs) {
|
|
145
145
|
return {
|
|
146
146
|
async probe(bytes) {
|
|
147
|
-
const info = imageDimensions(bytes); // header-only
|
|
147
|
+
const info = imageDimensions(bytes); // header-only - never allocates a decoded buffer
|
|
148
148
|
if (info === null) {
|
|
149
149
|
throw new ImageProcessingError("decode", "unrecognized image header");
|
|
150
150
|
}
|
package/dist/dimensions.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Read an image's intrinsic dimensions from its file **header**, in pure JS
|
|
2
|
+
* Read an image's intrinsic dimensions from its file **header**, in pure JS - no decode, no codec, no
|
|
3
3
|
* dependency. Supports PNG, JPEG, GIF, and WebP (VP8/VP8L/VP8X). Used to give `<Image>` CLS-safe
|
|
4
4
|
* `width`/`height` (build-time tooling can pre-read them into a manifest).
|
|
5
5
|
*/
|
|
@@ -14,7 +14,7 @@ export declare function imageDimensions(bytes: Uint8Array): ImageInfo | null;
|
|
|
14
14
|
/**
|
|
15
15
|
* Read just the leading bytes of an image file (via the platform `Bun.file`/`fetch` blob) and parse its
|
|
16
16
|
* dimensions. Build-time tooling: pre-read dimensions into a manifest so `<Image>` is CLS-safe without
|
|
17
|
-
* hardcoding sizes. Reads at most `maxBytes` (default 64 KB
|
|
17
|
+
* hardcoding sizes. Reads at most `maxBytes` (default 64 KB - enough for any header).
|
|
18
18
|
*/
|
|
19
19
|
export declare function readImageDimensions(source: {
|
|
20
20
|
arrayBuffer(): Promise<ArrayBuffer>;
|
package/dist/dimensions.js
CHANGED
|
@@ -18,7 +18,7 @@ const jpeg = (b, dv) => {
|
|
|
18
18
|
if (dv.getUint8(offset) !== 0xff)
|
|
19
19
|
return null; // not aligned on a marker → malformed
|
|
20
20
|
const marker = dv.getUint8(offset + 1);
|
|
21
|
-
// SOF0..SOF15 carry the frame's height/width
|
|
21
|
+
// SOF0..SOF15 carry the frame's height/width - except DHT(c4)/DNL(c8)/DAC(cc).
|
|
22
22
|
if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
|
|
23
23
|
return { height: dv.getUint16(offset + 5), width: dv.getUint16(offset + 7), format: "jpeg" };
|
|
24
24
|
}
|
|
@@ -64,7 +64,7 @@ export function imageDimensions(bytes) {
|
|
|
64
64
|
/**
|
|
65
65
|
* Read just the leading bytes of an image file (via the platform `Bun.file`/`fetch` blob) and parse its
|
|
66
66
|
* dimensions. Build-time tooling: pre-read dimensions into a manifest so `<Image>` is CLS-safe without
|
|
67
|
-
* hardcoding sizes. Reads at most `maxBytes` (default 64 KB
|
|
67
|
+
* hardcoding sizes. Reads at most `maxBytes` (default 64 KB - enough for any header).
|
|
68
68
|
*/
|
|
69
69
|
export async function readImageDimensions(source, maxBytes = 65_536) {
|
|
70
70
|
if (!Number.isSafeInteger(maxBytes) || maxBytes < 1) {
|
package/dist/html.d.ts
CHANGED
|
@@ -12,8 +12,8 @@ export interface HtmlImageAttrs {
|
|
|
12
12
|
readonly fetchpriority?: "high";
|
|
13
13
|
}
|
|
14
14
|
/**
|
|
15
|
-
* Map a {@link ResolvedImage} to plain lowercase HTML `<img>` attribute names
|
|
16
|
-
* `srcSet`), `fetchpriority` (not `fetchPriority`)
|
|
15
|
+
* Map a {@link ResolvedImage} to plain lowercase HTML `<img>` attribute names - `srcset` (not React's
|
|
16
|
+
* `srcSet`), `fetchpriority` (not `fetchPriority`) - dropping unset optionals. For the adapters that
|
|
17
17
|
* spread attributes straight onto a host element (Solid / Vue / Svelte / Preact); React consumes
|
|
18
18
|
* `ResolvedImage` directly because it requires the camelCased prop names.
|
|
19
19
|
*/
|
package/dist/html.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Map a {@link ResolvedImage} to plain lowercase HTML `<img>` attribute names
|
|
3
|
-
* `srcSet`), `fetchpriority` (not `fetchPriority`)
|
|
2
|
+
* Map a {@link ResolvedImage} to plain lowercase HTML `<img>` attribute names - `srcset` (not React's
|
|
3
|
+
* `srcSet`), `fetchpriority` (not `fetchPriority`) - dropping unset optionals. For the adapters that
|
|
4
4
|
* spread attributes straight onto a host element (Solid / Vue / Svelte / Preact); React consumes
|
|
5
5
|
* `ResolvedImage` directly because it requires the camelCased prop names.
|
|
6
6
|
*/
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `@nifrajs/image`
|
|
2
|
+
* `@nifrajs/image` - image optimization for nifra. A CLS-safe responsive `<Image>` (per-adapter) built on
|
|
3
3
|
* the pure attribute builder `resolveImage`, pluggable CDN `ImageLoader`s (Cloudflare Images + an
|
|
4
4
|
* identity default), and dependency-free intrinsic-dimension reading. nifra bundles no image codec; the
|
|
5
5
|
* resize is delegated to the edge CDN via the loader. Per-adapter `<Image>` lives in the adapter packages.
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `@nifrajs/image`
|
|
2
|
+
* `@nifrajs/image` - image optimization for nifra. A CLS-safe responsive `<Image>` (per-adapter) built on
|
|
3
3
|
* the pure attribute builder `resolveImage`, pluggable CDN `ImageLoader`s (Cloudflare Images + an
|
|
4
4
|
* identity default), and dependency-free intrinsic-dimension reading. nifra bundles no image codec; the
|
|
5
5
|
* resize is delegated to the edge CDN via the loader. Per-adapter `<Image>` lives in the adapter packages.
|
package/dist/resolve.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The pure attribute builder shared by every adapter `<Image>`
|
|
2
|
+
* The pure attribute builder shared by every adapter `<Image>` - turns image props into CLS-safe,
|
|
3
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)
|
|
4
|
+
* `fetchpriority`). The actual resize is delegated to an {@link ImageLoader} (a URL builder) - nifra
|
|
5
5
|
* bundles no codec; point the loader at your image CDN.
|
|
6
6
|
*/
|
|
7
7
|
/** Builds a variant URL for `src` at a target pixel `width` (and optional `quality`). */
|
|
@@ -10,7 +10,7 @@ export type ImageLoader = (args: {
|
|
|
10
10
|
width: number;
|
|
11
11
|
quality?: number;
|
|
12
12
|
}) => string;
|
|
13
|
-
/** Default loader: return the source unchanged (no transform). Use when there's no image CDN
|
|
13
|
+
/** Default loader: return the source unchanged (no transform). Use when there's no image CDN - you
|
|
14
14
|
* still get CLS-safe sizing + lazy loading, just no responsive variants. */
|
|
15
15
|
export declare const identityLoader: ImageLoader;
|
|
16
16
|
export interface CloudflareLoaderOptions {
|
|
@@ -18,7 +18,7 @@ export interface CloudflareLoaderOptions {
|
|
|
18
18
|
readonly base?: string;
|
|
19
19
|
}
|
|
20
20
|
/**
|
|
21
|
-
* Cloudflare Images loader
|
|
21
|
+
* Cloudflare Images loader - builds `/cdn-cgi/image/<options>/<source>` URLs that the Cloudflare edge
|
|
22
22
|
* resizes on the fly (also emits `format=auto` for webp/avif negotiation). Works on Cloudflare Pages /
|
|
23
23
|
* Workers with Images enabled.
|
|
24
24
|
*/
|
|
@@ -28,9 +28,9 @@ export interface SelfHostedLoaderOptions {
|
|
|
28
28
|
readonly endpoint: string;
|
|
29
29
|
/**
|
|
30
30
|
* HMAC secret for **signed URLs**. When set, each URL gets a stable `&s=` signature and the handler
|
|
31
|
-
* must be configured with the SAME `signing.secret`
|
|
31
|
+
* must be configured with the SAME `signing.secret` - it then rejects any unsigned/forged `(src, w, q)`,
|
|
32
32
|
* shutting down resize-bombing. ⚠️ The signer holds the secret, so a loader created with it is
|
|
33
|
-
* **server-only**
|
|
33
|
+
* **server-only** - inject it like a session secret (from `env`), never import this config into a
|
|
34
34
|
* route/client module. Signatures are stable (no expiry), so SSR-signed URLs hydrate + cache identically.
|
|
35
35
|
*/
|
|
36
36
|
readonly secret?: string;
|
|
@@ -43,13 +43,13 @@ export interface SelfHostedLoaderOptions {
|
|
|
43
43
|
*/
|
|
44
44
|
export declare function selfHostedLoader(options: SelfHostedLoaderOptions): ImageLoader;
|
|
45
45
|
export interface SignImageUrlOptions {
|
|
46
|
-
/** HMAC secret
|
|
46
|
+
/** HMAC secret - must match the handler's `signing.secret`. */
|
|
47
47
|
readonly secret: string;
|
|
48
48
|
/** Seconds until the URL expires (adds `&exp=`). Omit for a stable, cacheable-forever signed URL. */
|
|
49
49
|
readonly expiresIn?: number;
|
|
50
50
|
}
|
|
51
51
|
/**
|
|
52
|
-
* Mint a **signed** self-hosted image URL on the server
|
|
52
|
+
* Mint a **signed** self-hosted image URL on the server - for cases the (stable) `selfHostedLoader`
|
|
53
53
|
* doesn't cover, chiefly **time-limited** access (`expiresIn`) to private images. Server-only (it holds
|
|
54
54
|
* the secret). Pair with a passthrough loader, or use the signed string as a plain `src`.
|
|
55
55
|
*
|
|
@@ -64,11 +64,11 @@ export declare function signImageUrl(endpoint: string, image: {
|
|
|
64
64
|
}, options: SignImageUrlOptions): string;
|
|
65
65
|
export interface ImageProps {
|
|
66
66
|
readonly src: string;
|
|
67
|
-
/** Intrinsic width (px)
|
|
67
|
+
/** Intrinsic width (px) - **required**, reserves layout space (no CLS). */
|
|
68
68
|
readonly width: number;
|
|
69
|
-
/** Intrinsic height (px)
|
|
69
|
+
/** Intrinsic height (px) - **required** (no CLS). */
|
|
70
70
|
readonly height: number;
|
|
71
|
-
/** Alt text
|
|
71
|
+
/** Alt text - **required** for accessibility (use `alt=""` for decorative images). */
|
|
72
72
|
readonly alt: string;
|
|
73
73
|
/** `sizes` attribute for responsive selection (e.g. `"(max-width: 600px) 100vw, 600px"`). */
|
|
74
74
|
readonly sizes?: string;
|
package/dist/resolve.js
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The pure attribute builder shared by every adapter `<Image>`
|
|
2
|
+
* The pure attribute builder shared by every adapter `<Image>` - turns image props into CLS-safe,
|
|
3
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)
|
|
4
|
+
* `fetchpriority`). The actual resize is delegated to an {@link ImageLoader} (a URL builder) - nifra
|
|
5
5
|
* bundles no codec; point the loader at your image CDN.
|
|
6
6
|
*/
|
|
7
7
|
import { signImageParams } from "./sign.js";
|
|
8
|
-
/** Default loader: return the source unchanged (no transform). Use when there's no image CDN
|
|
8
|
+
/** Default loader: return the source unchanged (no transform). Use when there's no image CDN - you
|
|
9
9
|
* still get CLS-safe sizing + lazy loading, just no responsive variants. */
|
|
10
10
|
export const identityLoader = ({ src }) => src;
|
|
11
11
|
/**
|
|
12
|
-
* Cloudflare Images loader
|
|
12
|
+
* Cloudflare Images loader - builds `/cdn-cgi/image/<options>/<source>` URLs that the Cloudflare edge
|
|
13
13
|
* resizes on the fly (also emits `format=auto` for webp/avif negotiation). Works on Cloudflare Pages /
|
|
14
14
|
* Workers with Images enabled.
|
|
15
15
|
*/
|
|
@@ -41,7 +41,7 @@ export function selfHostedLoader(options) {
|
|
|
41
41
|
};
|
|
42
42
|
}
|
|
43
43
|
/**
|
|
44
|
-
* Mint a **signed** self-hosted image URL on the server
|
|
44
|
+
* Mint a **signed** self-hosted image URL on the server - for cases the (stable) `selfHostedLoader`
|
|
45
45
|
* doesn't cover, chiefly **time-limited** access (`expiresIn`) to private images. Server-only (it holds
|
|
46
46
|
* the secret). Pair with a passthrough loader, or use the signed string as a plain `src`.
|
|
47
47
|
*
|
|
@@ -74,7 +74,7 @@ export function resolveImage(props, loader = identityLoader) {
|
|
|
74
74
|
props.width <= 0 ||
|
|
75
75
|
!Number.isFinite(props.height) ||
|
|
76
76
|
props.height <= 0) {
|
|
77
|
-
throw new Error(`[nifra/image] <Image> requires positive width + height (got ${props.width}×${props.height})
|
|
77
|
+
throw new Error(`[nifra/image] <Image> requires positive width + height (got ${props.width}×${props.height}) - they reserve layout space to prevent CLS.`);
|
|
78
78
|
}
|
|
79
79
|
const eager = props.priority === true || props.loading === "eager";
|
|
80
80
|
const widths = [...new Set(props.widths ?? [props.width, props.width * 2])].sort((a, b) => a - b);
|
|
@@ -85,7 +85,7 @@ export function resolveImage(props, loader = identityLoader) {
|
|
|
85
85
|
const entries = widths.map((w) => ({ url: at(w), w }));
|
|
86
86
|
const allSame = entries.every((e) => e.url === entries[0]?.url);
|
|
87
87
|
const srcSet = allSame ? undefined : entries.map((e) => `${e.url} ${e.w}w`).join(", ");
|
|
88
|
-
// `src` is the 1× (intrinsic-width) variant
|
|
88
|
+
// `src` is the 1× (intrinsic-width) variant - the fallback for browsers ignoring srcSet.
|
|
89
89
|
const base = {
|
|
90
90
|
src: at(props.width),
|
|
91
91
|
width: props.width,
|
package/dist/server.d.ts
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `@nifrajs/image/server`
|
|
2
|
+
* `@nifrajs/image/server` - nifra's **self-hosted** image resize endpoint. `createImageHandler` returns a
|
|
3
3
|
* `(req: Request) => Promise<Response>` you mount at the path your {@link selfHostedLoader} points to
|
|
4
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
|
|
5
|
+
* a remote URL on an `allowedOrigins` allowlist - both **fail-closed** against SSRF), then decodes →
|
|
6
6
|
* resizes (never upscaling) → re-encodes via a pluggable {@link ImageBackend}. The default
|
|
7
7
|
* {@link bunImageBackend} uses `Bun.Image` (libjpeg-turbo / libspng / libwebp, off-thread).
|
|
8
8
|
*
|
|
9
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
|
|
10
|
+
* edge. On Workers / Vercel-Edge / Deno-Deploy there is no native codec - use the CDN `cloudflareLoader`
|
|
11
11
|
* from `@nifrajs/image` instead. The dependency-free core (`@nifrajs/image`) never imports this module.
|
|
12
12
|
*/
|
|
13
13
|
import { type ImageBackend } from "./backend.js";
|
|
@@ -23,7 +23,7 @@ export interface ImageHandlerOptions {
|
|
|
23
23
|
readonly allowedOrigins?: readonly string[];
|
|
24
24
|
/** Max bytes read from a source before rejecting (413). Default 20 MiB. */
|
|
25
25
|
readonly maxSourceBytes?: number;
|
|
26
|
-
/** Max source pixels (w×h) before rejecting (413)
|
|
26
|
+
/** Max source pixels (w×h) before rejecting (413) - decompression-bomb guard. Default 40 MP. */
|
|
27
27
|
readonly maxSourcePixels?: number;
|
|
28
28
|
/** Hard cap on the requested width; larger `?w` is clamped down. Default 3840. */
|
|
29
29
|
readonly maxWidth?: number;
|
|
@@ -40,7 +40,7 @@ export interface ImageHandlerOptions {
|
|
|
40
40
|
readonly fetch?: typeof fetch;
|
|
41
41
|
/** Require **signed URLs**: every request must carry a valid `&s=` HMAC over `(src, w, q[, exp])` or
|
|
42
42
|
* it's rejected with `403`. Use the SAME `secret` as your `selfHostedLoader`/`signImageUrl`. This
|
|
43
|
-
* locks the endpoint to URLs your app minted
|
|
43
|
+
* locks the endpoint to URLs your app minted - the defense against resize-bombing. */
|
|
44
44
|
readonly signing?: {
|
|
45
45
|
readonly secret: string;
|
|
46
46
|
};
|
package/dist/server.js
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `@nifrajs/image/server`
|
|
2
|
+
* `@nifrajs/image/server` - nifra's **self-hosted** image resize endpoint. `createImageHandler` returns a
|
|
3
3
|
* `(req: Request) => Promise<Response>` you mount at the path your {@link selfHostedLoader} points to
|
|
4
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
|
|
5
|
+
* a remote URL on an `allowedOrigins` allowlist - both **fail-closed** against SSRF), then decodes →
|
|
6
6
|
* resizes (never upscaling) → re-encodes via a pluggable {@link ImageBackend}. The default
|
|
7
7
|
* {@link bunImageBackend} uses `Bun.Image` (libjpeg-turbo / libspng / libwebp, off-thread).
|
|
8
8
|
*
|
|
9
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
|
|
10
|
+
* edge. On Workers / Vercel-Edge / Deno-Deploy there is no native codec - use the CDN `cloudflareLoader`
|
|
11
11
|
* from `@nifrajs/image` instead. The dependency-free core (`@nifrajs/image`) never imports this module.
|
|
12
12
|
*/
|
|
13
13
|
import { readFile, realpath, stat } from "node:fs/promises";
|
|
@@ -15,7 +15,7 @@ import { resolve as resolvePath, sep } from "node:path";
|
|
|
15
15
|
import { bunImageBackend, ImageProcessingError, } from "./backend.js";
|
|
16
16
|
import { verifyImageParams } from "./sign.js";
|
|
17
17
|
// The codec seam + every official backend (Bun/sharp/WASM) live in the edge-safe `./backend.ts`
|
|
18
|
-
// (`@nifrajs/image/backends`), importable WITHOUT this module's `node:fs`
|
|
18
|
+
// (`@nifrajs/image/backends`), importable WITHOUT this module's `node:fs` - so a WASM backend can ship to
|
|
19
19
|
// the edge. Re-export them so `@nifrajs/image/server` stays the single import for Node servers.
|
|
20
20
|
export * from "./backend.js";
|
|
21
21
|
const MAX_SRC_LEN = 2048;
|
|
@@ -51,11 +51,11 @@ function resolveConfig(options) {
|
|
|
51
51
|
};
|
|
52
52
|
}
|
|
53
53
|
async function handle(req, cfg) {
|
|
54
|
-
// 1. Method
|
|
54
|
+
// 1. Method - only safe reads. (HEAD shares the GET path; the body is stripped at the end.)
|
|
55
55
|
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
56
56
|
return errorResponse(405, "method_not_allowed", { Allow: "GET, HEAD" });
|
|
57
57
|
}
|
|
58
|
-
// 2. Validate the query at the trust boundary (strict scalar parsing
|
|
58
|
+
// 2. Validate the query at the trust boundary (strict scalar parsing - no Number() coercion).
|
|
59
59
|
const url = new URL(req.url);
|
|
60
60
|
const src = url.searchParams.get("src");
|
|
61
61
|
if (src === null || src.length === 0 || src.length > MAX_SRC_LEN) {
|
|
@@ -75,7 +75,7 @@ async function handle(req, cfg) {
|
|
|
75
75
|
const width = Math.min(cfg.maxWidth, requestedWidth);
|
|
76
76
|
const wantsWebp = (req.headers.get("accept") ?? "").includes("image/webp");
|
|
77
77
|
// 2b. Signed-URL enforcement (when configured): reject any request we didn't mint. Verifies the
|
|
78
|
-
// `&s=` HMAC over the raw (src, w, q[, exp])
|
|
78
|
+
// `&s=` HMAC over the raw (src, w, q[, exp]) - exactly what the loader/`signImageUrl` signed -
|
|
79
79
|
// and the expiry. Done before the ETag/fetch so unsigned/forged requests cost nothing.
|
|
80
80
|
if (cfg.signing !== null) {
|
|
81
81
|
const sig = url.searchParams.get("s");
|
|
@@ -136,7 +136,7 @@ async function handle(req, cfg) {
|
|
|
136
136
|
return errorResponse(413, "source_too_large");
|
|
137
137
|
return errorResponse(415, "unsupported_media_type"); // decode | unsupported
|
|
138
138
|
}
|
|
139
|
-
// Unexpected
|
|
139
|
+
// Unexpected - never leak internals to the client.
|
|
140
140
|
return errorResponse(500, "internal_error");
|
|
141
141
|
}
|
|
142
142
|
finally {
|
|
@@ -165,7 +165,7 @@ async function fetchRemote(url, cfg) {
|
|
|
165
165
|
let res;
|
|
166
166
|
try {
|
|
167
167
|
res = await cfg.fetchImpl(url, {
|
|
168
|
-
redirect: "error", // a redirect could bounce to a disallowed origin
|
|
168
|
+
redirect: "error", // a redirect could bounce to a disallowed origin - refuse it.
|
|
169
169
|
signal: AbortSignal.timeout(cfg.fetchTimeoutMs),
|
|
170
170
|
headers: { Accept: "image/*" },
|
|
171
171
|
});
|
|
@@ -186,7 +186,7 @@ async function fetchRemote(url, cfg) {
|
|
|
186
186
|
async function readLocal(src, cfg) {
|
|
187
187
|
if (cfg.root === null)
|
|
188
188
|
return { ok: false, status: 403, code: "source_not_allowed" };
|
|
189
|
-
// `src` is already percent-decoded by URLSearchParams
|
|
189
|
+
// `src` is already percent-decoded by URLSearchParams - do NOT decode again (double-decode bypass).
|
|
190
190
|
if (src.includes("\0"))
|
|
191
191
|
return { ok: false, status: 400, code: "invalid_src" };
|
|
192
192
|
// Strip leading slashes so a site-absolute ("/hero.jpg") or protocol-relative ("//evil") path is
|
|
@@ -207,7 +207,7 @@ async function readLocal(src, cfg) {
|
|
|
207
207
|
return { ok: false, status: 404, code: "source_not_found" };
|
|
208
208
|
if (info.size > cfg.maxSourceBytes)
|
|
209
209
|
return { ok: false, status: 413, code: "source_too_large" };
|
|
210
|
-
// Defense-in-depth: a symlink inside root could point outside it
|
|
210
|
+
// Defense-in-depth: a symlink inside root could point outside it - re-check the real path.
|
|
211
211
|
try {
|
|
212
212
|
const real = await realpath(resolved);
|
|
213
213
|
const realRoot = await realpath(cfg.root);
|
|
@@ -221,7 +221,7 @@ async function readLocal(src, cfg) {
|
|
|
221
221
|
return { ok: true, bytes: new Uint8Array(await readFile(resolved)) };
|
|
222
222
|
}
|
|
223
223
|
/** Read a response body, aborting (→ null) once `limit` bytes are exceeded. Trusts neither the
|
|
224
|
-
* `Content-Length` header (checked as a fast reject) nor an absent one
|
|
224
|
+
* `Content-Length` header (checked as a fast reject) nor an absent one - the running total is the gate. */
|
|
225
225
|
async function readBoundedBytes(res, limit) {
|
|
226
226
|
const declared = res.headers.get("content-length");
|
|
227
227
|
if (declared !== null && /^\d+$/.test(declared) && Number(declared) > limit)
|
|
@@ -302,7 +302,7 @@ function errorResponse(status, code, extraHeaders = {}) {
|
|
|
302
302
|
});
|
|
303
303
|
}
|
|
304
304
|
/**
|
|
305
|
-
* Async counting semaphore with direct slot hand-off
|
|
305
|
+
* Async counting semaphore with direct slot hand-off - a released slot is passed straight to the next
|
|
306
306
|
* waiter (the active count is never transiently decremented), so a concurrent `acquire()` can't slip
|
|
307
307
|
* past the limit in the microtask gap. Bounds concurrent codec work.
|
|
308
308
|
*/
|
package/dist/sign.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Signed image URLs
|
|
2
|
+
* Signed image URLs - a portable, **synchronous** HMAC-SHA256 so the (sync) `selfHostedLoader` can sign
|
|
3
3
|
* URLs inline, on any runtime including the edge (WebCrypto's HMAC is async-only; `node:crypto` isn't on
|
|
4
|
-
* Workers). A signed URL lets `createImageHandler` reject any `(src, w, q)` it didn't authorize
|
|
4
|
+
* Workers). A signed URL lets `createImageHandler` reject any `(src, w, q)` it didn't authorize -
|
|
5
5
|
* shutting down resize-bombing (width/quality enumeration) and locking the endpoint to your own images.
|
|
6
6
|
*
|
|
7
7
|
* Pure JS, dependency-free, KAT-tested against the SHA-256 / RFC 4231 HMAC vectors. The secret never
|
package/dist/sign.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Signed image URLs
|
|
2
|
+
* Signed image URLs - a portable, **synchronous** HMAC-SHA256 so the (sync) `selfHostedLoader` can sign
|
|
3
3
|
* URLs inline, on any runtime including the edge (WebCrypto's HMAC is async-only; `node:crypto` isn't on
|
|
4
|
-
* Workers). A signed URL lets `createImageHandler` reject any `(src, w, q)` it didn't authorize
|
|
4
|
+
* Workers). A signed URL lets `createImageHandler` reject any `(src, w, q)` it didn't authorize -
|
|
5
5
|
* shutting down resize-bombing (width/quality enumeration) and locking the endpoint to your own images.
|
|
6
6
|
*
|
|
7
7
|
* Pure JS, dependency-free, KAT-tested against the SHA-256 / RFC 4231 HMAC vectors. The secret never
|
|
@@ -117,14 +117,14 @@ const hex = (bytes) => {
|
|
|
117
117
|
export function hmacSha256Hex(key, message) {
|
|
118
118
|
return hex(hmacSha256(encoder.encode(key), encoder.encode(message)));
|
|
119
119
|
}
|
|
120
|
-
/** base64url (RFC 4648 §5, unpadded) of a byte array
|
|
120
|
+
/** base64url (RFC 4648 §5, unpadded) of a byte array - URL-safe, compact (43 chars for a 32-byte tag). */
|
|
121
121
|
function base64url(bytes) {
|
|
122
122
|
let binary = "";
|
|
123
123
|
for (const b of bytes)
|
|
124
124
|
binary += String.fromCharCode(b);
|
|
125
125
|
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
126
126
|
}
|
|
127
|
-
/** Constant-time string compare
|
|
127
|
+
/** Constant-time string compare - length-independent of where the first difference is. */
|
|
128
128
|
function safeEqual(a, b) {
|
|
129
129
|
if (a.length !== b.length)
|
|
130
130
|
return false;
|
|
@@ -134,7 +134,7 @@ function safeEqual(a, b) {
|
|
|
134
134
|
return diff === 0;
|
|
135
135
|
}
|
|
136
136
|
const canonical = (p) => `${p.src}\n${p.w}\n${p.q ?? ""}\n${p.exp ?? ""}`;
|
|
137
|
-
// 256-bit secret floor
|
|
137
|
+
// 256-bit secret floor - mirrors @nifrajs/core's HMAC guard. This package is dependency-free (ships
|
|
138
138
|
// its own SHA-256), so the constant is inlined rather than imported.
|
|
139
139
|
const MIN_SECRET_BYTES = 32;
|
|
140
140
|
/** Compute the URL signature (base64url HMAC-SHA256) for a set of image params. Guards (and so does
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nifrajs/image",
|
|
3
|
-
"version": "2.
|
|
4
|
-
"description": "Image optimization for nifra
|
|
3
|
+
"version": "2.4.0",
|
|
4
|
+
"description": "Image optimization for nifra - a CLS-safe responsive <Image> + pluggable loaders (CDN or self-hosted) + pure-JS dimension reading. Zero-dependency core; optional Bun.Image-backed resize endpoint at @nifrajs/image/server.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"bun",
|
|
7
7
|
"typescript",
|
package/src/backend.ts
CHANGED
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `@nifrajs/image/backends`
|
|
2
|
+
* `@nifrajs/image/backends` - the codec seam + the official {@link ImageBackend} implementations, kept
|
|
3
3
|
* **dependency-free and edge-safe** (no `node:` imports), so the WASM backend can run on Workers /
|
|
4
|
-
* Vercel-Edge / Deno-Deploy. `createImageHandler` (in `@nifrajs/image/server`, Node-only
|
|
4
|
+
* Vercel-Edge / Deno-Deploy. `createImageHandler` (in `@nifrajs/image/server`, Node-only - it touches the
|
|
5
5
|
* filesystem) consumes this seam and owns all request-level security; a backend only
|
|
6
6
|
* decodes/resizes/encodes and translates codec failures into {@link ImageProcessingError}.
|
|
7
7
|
*
|
|
8
8
|
* Pick a backend for your runtime:
|
|
9
|
-
* - {@link bunImageBackend}
|
|
10
|
-
* - {@link sharpImageBackend}
|
|
11
|
-
* - {@link wasmImageBackend}
|
|
9
|
+
* - {@link bunImageBackend} - `Bun.Image` (libjpeg-turbo / libspng / libwebp). Bun servers.
|
|
10
|
+
* - {@link sharpImageBackend} - pass your `sharp` import. Node servers.
|
|
11
|
+
* - {@link wasmImageBackend} - pass WASM codecs (e.g. jSquash). Any runtime, including the edge.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import { imageDimensions } from "./dimensions.ts"
|
|
15
15
|
|
|
16
|
-
/** Output formats nifra's endpoint can emit. AVIF is intentionally excluded
|
|
16
|
+
/** Output formats nifra's endpoint can emit. AVIF is intentionally excluded - `Bun.Image` reports
|
|
17
17
|
* `ERR_IMAGE_FORMAT_UNSUPPORTED` for AVIF encode on common platforms, so offering it would 500. */
|
|
18
18
|
export type OutputFormat = "webp" | "jpeg" | "png"
|
|
19
19
|
|
|
@@ -24,7 +24,7 @@ export const CONTENT_TYPE: Record<OutputFormat, string> = {
|
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
/** Header-only probe of a source image: intrinsic dimensions + decoded format. Must be cheap (no full
|
|
27
|
-
* decode)
|
|
27
|
+
* decode) - it gates the decompression-bomb and no-upscale checks before the expensive resize. */
|
|
28
28
|
export interface ImageProbe {
|
|
29
29
|
readonly width: number
|
|
30
30
|
readonly height: number
|
|
@@ -34,7 +34,7 @@ export interface ImageProbe {
|
|
|
34
34
|
|
|
35
35
|
export interface ResizeInput {
|
|
36
36
|
readonly bytes: Uint8Array
|
|
37
|
-
/** Target width in px
|
|
37
|
+
/** Target width in px - already clamped to `[1, maxWidth]` **and** to the source's intrinsic width
|
|
38
38
|
* (the handler never asks a backend to upscale). Aspect ratio is preserved. */
|
|
39
39
|
readonly width: number
|
|
40
40
|
/** Encoder quality `1..100` (ignored for lossless `png`). */
|
|
@@ -141,7 +141,7 @@ function toBunError(err: unknown): ImageProcessingError {
|
|
|
141
141
|
// --- sharp backend (Node) ----------------------------------------------------------------------------
|
|
142
142
|
|
|
143
143
|
/** The slice of a [sharp](https://sharp.pixelplumbing.com) instance this backend uses. Declared
|
|
144
|
-
* structurally so `@nifrajs/image` has no dependency on sharp
|
|
144
|
+
* structurally so `@nifrajs/image` has no dependency on sharp - pass your own `sharp` import. */
|
|
145
145
|
export type SharpLike = (input: Uint8Array) => SharpInstance
|
|
146
146
|
interface SharpInstance {
|
|
147
147
|
metadata(): Promise<{ width?: number; height?: number; format?: string }>
|
|
@@ -154,7 +154,7 @@ interface SharpInstance {
|
|
|
154
154
|
|
|
155
155
|
/**
|
|
156
156
|
* {@link ImageBackend} backed by [sharp](https://sharp.pixelplumbing.com) (libvips) for Node servers.
|
|
157
|
-
* Pass your `sharp` import
|
|
157
|
+
* Pass your `sharp` import - `@nifrajs/image` never imports it, so it stays dependency-free and you control
|
|
158
158
|
* the version:
|
|
159
159
|
*
|
|
160
160
|
* ```ts
|
|
@@ -208,7 +208,7 @@ function toSharpError(err: unknown): ImageProcessingError {
|
|
|
208
208
|
|
|
209
209
|
// --- WASM backend (edge-portable) --------------------------------------------------------------------
|
|
210
210
|
|
|
211
|
-
/** A decoded image: RGBA pixels + dimensions
|
|
211
|
+
/** A decoded image: RGBA pixels + dimensions - the lingua franca of WASM codecs (jSquash, Photon, …). */
|
|
212
212
|
export interface DecodedImage {
|
|
213
213
|
/** RGBA bytes, length `width * height * 4`. */
|
|
214
214
|
readonly data: Uint8Array
|
|
@@ -217,7 +217,7 @@ export interface DecodedImage {
|
|
|
217
217
|
}
|
|
218
218
|
|
|
219
219
|
/**
|
|
220
|
-
* Pluggable WASM codec set
|
|
220
|
+
* Pluggable WASM codec set - decode/resize/encode. Declared structurally so `@nifrajs/image` depends on no
|
|
221
221
|
* WASM library; wire your own (jSquash is the common pure-WASM, edge-safe choice). The handler probes
|
|
222
222
|
* dimensions from the source header (bomb-safe), so `decode` runs only inside `transform`.
|
|
223
223
|
*/
|
|
@@ -235,7 +235,7 @@ export interface WasmImageCodecs {
|
|
|
235
235
|
}
|
|
236
236
|
|
|
237
237
|
/**
|
|
238
|
-
* {@link ImageBackend} backed by injected WASM codecs
|
|
238
|
+
* {@link ImageBackend} backed by injected WASM codecs - the only backend that runs on the **edge**
|
|
239
239
|
* (Workers / Vercel-Edge / Deno-Deploy), where neither `Bun.Image` nor sharp exists. `probe` reads the
|
|
240
240
|
* source header via nifra's dependency-free reader (so decompression bombs are rejected before any
|
|
241
241
|
* decode); `transform` decodes → resizes (aspect-preserving) → encodes through your codecs.
|
|
@@ -249,7 +249,7 @@ export interface WasmImageCodecs {
|
|
|
249
249
|
export function wasmImageBackend(codecs: WasmImageCodecs): ImageBackend {
|
|
250
250
|
return {
|
|
251
251
|
async probe(bytes) {
|
|
252
|
-
const info = imageDimensions(bytes) // header-only
|
|
252
|
+
const info = imageDimensions(bytes) // header-only - never allocates a decoded buffer
|
|
253
253
|
if (info === null) {
|
|
254
254
|
throw new ImageProcessingError("decode", "unrecognized image header")
|
|
255
255
|
}
|
package/src/dimensions.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Read an image's intrinsic dimensions from its file **header**, in pure JS
|
|
2
|
+
* Read an image's intrinsic dimensions from its file **header**, in pure JS - no decode, no codec, no
|
|
3
3
|
* dependency. Supports PNG, JPEG, GIF, and WebP (VP8/VP8L/VP8X). Used to give `<Image>` CLS-safe
|
|
4
4
|
* `width`/`height` (build-time tooling can pre-read them into a manifest).
|
|
5
5
|
*/
|
|
@@ -29,7 +29,7 @@ const jpeg = (b: Uint8Array, dv: DataView): ImageInfo | null => {
|
|
|
29
29
|
while (offset + 9 < b.length) {
|
|
30
30
|
if (dv.getUint8(offset) !== 0xff) return null // not aligned on a marker → malformed
|
|
31
31
|
const marker = dv.getUint8(offset + 1)
|
|
32
|
-
// SOF0..SOF15 carry the frame's height/width
|
|
32
|
+
// SOF0..SOF15 carry the frame's height/width - except DHT(c4)/DNL(c8)/DAC(cc).
|
|
33
33
|
if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
|
|
34
34
|
return { height: dv.getUint16(offset + 5), width: dv.getUint16(offset + 7), format: "jpeg" }
|
|
35
35
|
}
|
|
@@ -80,7 +80,7 @@ export function imageDimensions(bytes: Uint8Array): ImageInfo | null {
|
|
|
80
80
|
/**
|
|
81
81
|
* Read just the leading bytes of an image file (via the platform `Bun.file`/`fetch` blob) and parse its
|
|
82
82
|
* dimensions. Build-time tooling: pre-read dimensions into a manifest so `<Image>` is CLS-safe without
|
|
83
|
-
* hardcoding sizes. Reads at most `maxBytes` (default 64 KB
|
|
83
|
+
* hardcoding sizes. Reads at most `maxBytes` (default 64 KB - enough for any header).
|
|
84
84
|
*/
|
|
85
85
|
export async function readImageDimensions(
|
|
86
86
|
source: { arrayBuffer(): Promise<ArrayBuffer>; stream?: () => ReadableStream<Uint8Array> },
|
package/src/html.ts
CHANGED
|
@@ -14,8 +14,8 @@ export interface HtmlImageAttrs {
|
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
/**
|
|
17
|
-
* Map a {@link ResolvedImage} to plain lowercase HTML `<img>` attribute names
|
|
18
|
-
* `srcSet`), `fetchpriority` (not `fetchPriority`)
|
|
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
19
|
* spread attributes straight onto a host element (Solid / Vue / Svelte / Preact); React consumes
|
|
20
20
|
* `ResolvedImage` directly because it requires the camelCased prop names.
|
|
21
21
|
*/
|
package/src/index.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `@nifrajs/image`
|
|
2
|
+
* `@nifrajs/image` - image optimization for nifra. A CLS-safe responsive `<Image>` (per-adapter) built on
|
|
3
3
|
* the pure attribute builder `resolveImage`, pluggable CDN `ImageLoader`s (Cloudflare Images + an
|
|
4
4
|
* identity default), and dependency-free intrinsic-dimension reading. nifra bundles no image codec; the
|
|
5
5
|
* resize is delegated to the edge CDN via the loader. Per-adapter `<Image>` lives in the adapter packages.
|
package/src/resolve.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The pure attribute builder shared by every adapter `<Image>`
|
|
2
|
+
* The pure attribute builder shared by every adapter `<Image>` - turns image props into CLS-safe,
|
|
3
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)
|
|
4
|
+
* `fetchpriority`). The actual resize is delegated to an {@link ImageLoader} (a URL builder) - nifra
|
|
5
5
|
* bundles no codec; point the loader at your image CDN.
|
|
6
6
|
*/
|
|
7
7
|
|
|
@@ -10,7 +10,7 @@ import { signImageParams } from "./sign.ts"
|
|
|
10
10
|
/** Builds a variant URL for `src` at a target pixel `width` (and optional `quality`). */
|
|
11
11
|
export type ImageLoader = (args: { src: string; width: number; quality?: number }) => string
|
|
12
12
|
|
|
13
|
-
/** Default loader: return the source unchanged (no transform). Use when there's no image CDN
|
|
13
|
+
/** Default loader: return the source unchanged (no transform). Use when there's no image CDN - you
|
|
14
14
|
* still get CLS-safe sizing + lazy loading, just no responsive variants. */
|
|
15
15
|
export const identityLoader: ImageLoader = ({ src }) => src
|
|
16
16
|
|
|
@@ -20,7 +20,7 @@ export interface CloudflareLoaderOptions {
|
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
/**
|
|
23
|
-
* Cloudflare Images loader
|
|
23
|
+
* Cloudflare Images loader - builds `/cdn-cgi/image/<options>/<source>` URLs that the Cloudflare edge
|
|
24
24
|
* resizes on the fly (also emits `format=auto` for webp/avif negotiation). Works on Cloudflare Pages /
|
|
25
25
|
* Workers with Images enabled.
|
|
26
26
|
*/
|
|
@@ -38,9 +38,9 @@ export interface SelfHostedLoaderOptions {
|
|
|
38
38
|
readonly endpoint: string
|
|
39
39
|
/**
|
|
40
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`
|
|
41
|
+
* must be configured with the SAME `signing.secret` - it then rejects any unsigned/forged `(src, w, q)`,
|
|
42
42
|
* shutting down resize-bombing. ⚠️ The signer holds the secret, so a loader created with it is
|
|
43
|
-
* **server-only**
|
|
43
|
+
* **server-only** - inject it like a session secret (from `env`), never import this config into a
|
|
44
44
|
* route/client module. Signatures are stable (no expiry), so SSR-signed URLs hydrate + cache identically.
|
|
45
45
|
*/
|
|
46
46
|
readonly secret?: string
|
|
@@ -65,14 +65,14 @@ export function selfHostedLoader(options: SelfHostedLoaderOptions): ImageLoader
|
|
|
65
65
|
}
|
|
66
66
|
|
|
67
67
|
export interface SignImageUrlOptions {
|
|
68
|
-
/** HMAC secret
|
|
68
|
+
/** HMAC secret - must match the handler's `signing.secret`. */
|
|
69
69
|
readonly secret: string
|
|
70
70
|
/** Seconds until the URL expires (adds `&exp=`). Omit for a stable, cacheable-forever signed URL. */
|
|
71
71
|
readonly expiresIn?: number
|
|
72
72
|
}
|
|
73
73
|
|
|
74
74
|
/**
|
|
75
|
-
* Mint a **signed** self-hosted image URL on the server
|
|
75
|
+
* Mint a **signed** self-hosted image URL on the server - for cases the (stable) `selfHostedLoader`
|
|
76
76
|
* doesn't cover, chiefly **time-limited** access (`expiresIn`) to private images. Server-only (it holds
|
|
77
77
|
* the secret). Pair with a passthrough loader, or use the signed string as a plain `src`.
|
|
78
78
|
*
|
|
@@ -100,11 +100,11 @@ export function signImageUrl(
|
|
|
100
100
|
|
|
101
101
|
export interface ImageProps {
|
|
102
102
|
readonly src: string
|
|
103
|
-
/** Intrinsic width (px)
|
|
103
|
+
/** Intrinsic width (px) - **required**, reserves layout space (no CLS). */
|
|
104
104
|
readonly width: number
|
|
105
|
-
/** Intrinsic height (px)
|
|
105
|
+
/** Intrinsic height (px) - **required** (no CLS). */
|
|
106
106
|
readonly height: number
|
|
107
|
-
/** Alt text
|
|
107
|
+
/** Alt text - **required** for accessibility (use `alt=""` for decorative images). */
|
|
108
108
|
readonly alt: string
|
|
109
109
|
/** `sizes` attribute for responsive selection (e.g. `"(max-width: 600px) 100vw, 600px"`). */
|
|
110
110
|
readonly sizes?: string
|
|
@@ -147,7 +147,7 @@ export function resolveImage(
|
|
|
147
147
|
props.height <= 0
|
|
148
148
|
) {
|
|
149
149
|
throw new Error(
|
|
150
|
-
`[nifra/image] <Image> requires positive width + height (got ${props.width}×${props.height})
|
|
150
|
+
`[nifra/image] <Image> requires positive width + height (got ${props.width}×${props.height}) - they reserve layout space to prevent CLS.`,
|
|
151
151
|
)
|
|
152
152
|
}
|
|
153
153
|
const eager = props.priority === true || props.loading === "eager"
|
|
@@ -164,7 +164,7 @@ export function resolveImage(
|
|
|
164
164
|
const allSame = entries.every((e) => e.url === entries[0]?.url)
|
|
165
165
|
const srcSet = allSame ? undefined : entries.map((e) => `${e.url} ${e.w}w`).join(", ")
|
|
166
166
|
|
|
167
|
-
// `src` is the 1× (intrinsic-width) variant
|
|
167
|
+
// `src` is the 1× (intrinsic-width) variant - the fallback for browsers ignoring srcSet.
|
|
168
168
|
const base: ResolvedImage = {
|
|
169
169
|
src: at(props.width),
|
|
170
170
|
width: props.width,
|
package/src/server.ts
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `@nifrajs/image/server`
|
|
2
|
+
* `@nifrajs/image/server` - nifra's **self-hosted** image resize endpoint. `createImageHandler` returns a
|
|
3
3
|
* `(req: Request) => Promise<Response>` you mount at the path your {@link selfHostedLoader} points to
|
|
4
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
|
|
5
|
+
* a remote URL on an `allowedOrigins` allowlist - both **fail-closed** against SSRF), then decodes →
|
|
6
6
|
* resizes (never upscaling) → re-encodes via a pluggable {@link ImageBackend}. The default
|
|
7
7
|
* {@link bunImageBackend} uses `Bun.Image` (libjpeg-turbo / libspng / libwebp, off-thread).
|
|
8
8
|
*
|
|
9
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
|
|
10
|
+
* edge. On Workers / Vercel-Edge / Deno-Deploy there is no native codec - use the CDN `cloudflareLoader`
|
|
11
11
|
* from `@nifrajs/image` instead. The dependency-free core (`@nifrajs/image`) never imports this module.
|
|
12
12
|
*/
|
|
13
13
|
|
|
@@ -24,7 +24,7 @@ import {
|
|
|
24
24
|
import { verifyImageParams } from "./sign.ts"
|
|
25
25
|
|
|
26
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`
|
|
27
|
+
// (`@nifrajs/image/backends`), importable WITHOUT this module's `node:fs` - so a WASM backend can ship to
|
|
28
28
|
// the edge. Re-export them so `@nifrajs/image/server` stays the single import for Node servers.
|
|
29
29
|
export * from "./backend.ts"
|
|
30
30
|
|
|
@@ -39,7 +39,7 @@ export interface ImageHandlerOptions {
|
|
|
39
39
|
readonly allowedOrigins?: readonly string[]
|
|
40
40
|
/** Max bytes read from a source before rejecting (413). Default 20 MiB. */
|
|
41
41
|
readonly maxSourceBytes?: number
|
|
42
|
-
/** Max source pixels (w×h) before rejecting (413)
|
|
42
|
+
/** Max source pixels (w×h) before rejecting (413) - decompression-bomb guard. Default 40 MP. */
|
|
43
43
|
readonly maxSourcePixels?: number
|
|
44
44
|
/** Hard cap on the requested width; larger `?w` is clamped down. Default 3840. */
|
|
45
45
|
readonly maxWidth?: number
|
|
@@ -56,7 +56,7 @@ export interface ImageHandlerOptions {
|
|
|
56
56
|
readonly fetch?: typeof fetch
|
|
57
57
|
/** Require **signed URLs**: every request must carry a valid `&s=` HMAC over `(src, w, q[, exp])` or
|
|
58
58
|
* it's rejected with `403`. Use the SAME `secret` as your `selfHostedLoader`/`signImageUrl`. This
|
|
59
|
-
* locks the endpoint to URLs your app minted
|
|
59
|
+
* locks the endpoint to URLs your app minted - the defense against resize-bombing. */
|
|
60
60
|
readonly signing?: { readonly secret: string }
|
|
61
61
|
}
|
|
62
62
|
|
|
@@ -114,12 +114,12 @@ function resolveConfig(options: ImageHandlerOptions): ResolvedConfig {
|
|
|
114
114
|
}
|
|
115
115
|
|
|
116
116
|
async function handle(req: Request, cfg: ResolvedConfig): Promise<Response> {
|
|
117
|
-
// 1. Method
|
|
117
|
+
// 1. Method - only safe reads. (HEAD shares the GET path; the body is stripped at the end.)
|
|
118
118
|
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
119
119
|
return errorResponse(405, "method_not_allowed", { Allow: "GET, HEAD" })
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
-
// 2. Validate the query at the trust boundary (strict scalar parsing
|
|
122
|
+
// 2. Validate the query at the trust boundary (strict scalar parsing - no Number() coercion).
|
|
123
123
|
const url = new URL(req.url)
|
|
124
124
|
const src = url.searchParams.get("src")
|
|
125
125
|
if (src === null || src.length === 0 || src.length > MAX_SRC_LEN) {
|
|
@@ -138,7 +138,7 @@ async function handle(req: Request, cfg: ResolvedConfig): Promise<Response> {
|
|
|
138
138
|
const wantsWebp = (req.headers.get("accept") ?? "").includes("image/webp")
|
|
139
139
|
|
|
140
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])
|
|
141
|
+
// `&s=` HMAC over the raw (src, w, q[, exp]) - exactly what the loader/`signImageUrl` signed -
|
|
142
142
|
// and the expiry. Done before the ETag/fetch so unsigned/forged requests cost nothing.
|
|
143
143
|
if (cfg.signing !== null) {
|
|
144
144
|
const sig = url.searchParams.get("s")
|
|
@@ -204,7 +204,7 @@ async function handle(req: Request, cfg: ResolvedConfig): Promise<Response> {
|
|
|
204
204
|
if (err.kind === "too_large") return errorResponse(413, "source_too_large")
|
|
205
205
|
return errorResponse(415, "unsupported_media_type") // decode | unsupported
|
|
206
206
|
}
|
|
207
|
-
// Unexpected
|
|
207
|
+
// Unexpected - never leak internals to the client.
|
|
208
208
|
return errorResponse(500, "internal_error")
|
|
209
209
|
} finally {
|
|
210
210
|
cfg.release()
|
|
@@ -238,7 +238,7 @@ async function fetchRemote(url: URL, cfg: ResolvedConfig): Promise<SourceResult>
|
|
|
238
238
|
let res: Response
|
|
239
239
|
try {
|
|
240
240
|
res = await cfg.fetchImpl(url, {
|
|
241
|
-
redirect: "error", // a redirect could bounce to a disallowed origin
|
|
241
|
+
redirect: "error", // a redirect could bounce to a disallowed origin - refuse it.
|
|
242
242
|
signal: AbortSignal.timeout(cfg.fetchTimeoutMs),
|
|
243
243
|
headers: { Accept: "image/*" },
|
|
244
244
|
})
|
|
@@ -256,7 +256,7 @@ async function fetchRemote(url: URL, cfg: ResolvedConfig): Promise<SourceResult>
|
|
|
256
256
|
|
|
257
257
|
async function readLocal(src: string, cfg: ResolvedConfig): Promise<SourceResult> {
|
|
258
258
|
if (cfg.root === null) return { ok: false, status: 403, code: "source_not_allowed" }
|
|
259
|
-
// `src` is already percent-decoded by URLSearchParams
|
|
259
|
+
// `src` is already percent-decoded by URLSearchParams - do NOT decode again (double-decode bypass).
|
|
260
260
|
if (src.includes("\0")) return { ok: false, status: 400, code: "invalid_src" }
|
|
261
261
|
// Strip leading slashes so a site-absolute ("/hero.jpg") or protocol-relative ("//evil") path is
|
|
262
262
|
// treated as root-relative; `resolve` then collapses any "../" and the containment check rejects it.
|
|
@@ -273,7 +273,7 @@ async function readLocal(src: string, cfg: ResolvedConfig): Promise<SourceResult
|
|
|
273
273
|
}
|
|
274
274
|
if (!info.isFile()) return { ok: false, status: 404, code: "source_not_found" }
|
|
275
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
|
|
276
|
+
// Defense-in-depth: a symlink inside root could point outside it - re-check the real path.
|
|
277
277
|
try {
|
|
278
278
|
const real = await realpath(resolved)
|
|
279
279
|
const realRoot = await realpath(cfg.root)
|
|
@@ -287,7 +287,7 @@ async function readLocal(src: string, cfg: ResolvedConfig): Promise<SourceResult
|
|
|
287
287
|
}
|
|
288
288
|
|
|
289
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
|
|
290
|
+
* `Content-Length` header (checked as a fast reject) nor an absent one - the running total is the gate. */
|
|
291
291
|
async function readBoundedBytes(res: Response, limit: number): Promise<Uint8Array | null> {
|
|
292
292
|
const declared = res.headers.get("content-length")
|
|
293
293
|
if (declared !== null && /^\d+$/.test(declared) && Number(declared) > limit) return null
|
|
@@ -371,7 +371,7 @@ function errorResponse(
|
|
|
371
371
|
}
|
|
372
372
|
|
|
373
373
|
/**
|
|
374
|
-
* Async counting semaphore with direct slot hand-off
|
|
374
|
+
* Async counting semaphore with direct slot hand-off - a released slot is passed straight to the next
|
|
375
375
|
* waiter (the active count is never transiently decremented), so a concurrent `acquire()` can't slip
|
|
376
376
|
* past the limit in the microtask gap. Bounds concurrent codec work.
|
|
377
377
|
*/
|
package/src/sign.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Signed image URLs
|
|
2
|
+
* Signed image URLs - a portable, **synchronous** HMAC-SHA256 so the (sync) `selfHostedLoader` can sign
|
|
3
3
|
* URLs inline, on any runtime including the edge (WebCrypto's HMAC is async-only; `node:crypto` isn't on
|
|
4
|
-
* Workers). A signed URL lets `createImageHandler` reject any `(src, w, q)` it didn't authorize
|
|
4
|
+
* Workers). A signed URL lets `createImageHandler` reject any `(src, w, q)` it didn't authorize -
|
|
5
5
|
* shutting down resize-bombing (width/quality enumeration) and locking the endpoint to your own images.
|
|
6
6
|
*
|
|
7
7
|
* Pure JS, dependency-free, KAT-tested against the SHA-256 / RFC 4231 HMAC vectors. The secret never
|
|
@@ -124,14 +124,14 @@ export function hmacSha256Hex(key: string, message: string): string {
|
|
|
124
124
|
return hex(hmacSha256(encoder.encode(key), encoder.encode(message)))
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
-
/** base64url (RFC 4648 §5, unpadded) of a byte array
|
|
127
|
+
/** base64url (RFC 4648 §5, unpadded) of a byte array - URL-safe, compact (43 chars for a 32-byte tag). */
|
|
128
128
|
function base64url(bytes: Uint8Array): string {
|
|
129
129
|
let binary = ""
|
|
130
130
|
for (const b of bytes) binary += String.fromCharCode(b)
|
|
131
131
|
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")
|
|
132
132
|
}
|
|
133
133
|
|
|
134
|
-
/** Constant-time string compare
|
|
134
|
+
/** Constant-time string compare - length-independent of where the first difference is. */
|
|
135
135
|
function safeEqual(a: string, b: string): boolean {
|
|
136
136
|
if (a.length !== b.length) return false
|
|
137
137
|
let diff = 0
|
|
@@ -154,7 +154,7 @@ export interface ImageSignatureParts {
|
|
|
154
154
|
const canonical = (p: ImageSignatureParts): string =>
|
|
155
155
|
`${p.src}\n${p.w}\n${p.q ?? ""}\n${p.exp ?? ""}`
|
|
156
156
|
|
|
157
|
-
// 256-bit secret floor
|
|
157
|
+
// 256-bit secret floor - mirrors @nifrajs/core's HMAC guard. This package is dependency-free (ships
|
|
158
158
|
// its own SHA-256), so the constant is inlined rather than imported.
|
|
159
159
|
const MIN_SECRET_BYTES = 32
|
|
160
160
|
|