@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/dist/sign.d.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Signed image URLs — a portable, **synchronous** HMAC-SHA256 so the (sync) `selfHostedLoader` can sign
|
|
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 —
|
|
5
|
+
* shutting down resize-bombing (width/quality enumeration) and locking the endpoint to your own images.
|
|
6
|
+
*
|
|
7
|
+
* Pure JS, dependency-free, KAT-tested against the SHA-256 / RFC 4231 HMAC vectors. The secret never
|
|
8
|
+
* leaves the bytes you pass in.
|
|
9
|
+
*/
|
|
10
|
+
/** HMAC-SHA256 of `message` under `key` (both UTF-8 strings) → lowercase hex. The primitive behind URL
|
|
11
|
+
* signing; exported for tests (RFC 4231 vectors) and advanced use. */
|
|
12
|
+
export declare function hmacSha256Hex(key: string, message: string): string;
|
|
13
|
+
/** The fields a signature covers. Newline-delimited into a canonical string (newlines can't appear in
|
|
14
|
+
* a URL query value, so the join is unambiguous). `q`/`exp` are normalized to `""` when absent so the
|
|
15
|
+
* signer and verifier agree. */
|
|
16
|
+
export interface ImageSignatureParts {
|
|
17
|
+
readonly src: string;
|
|
18
|
+
readonly w: string;
|
|
19
|
+
readonly q?: string | undefined;
|
|
20
|
+
readonly exp?: string | undefined;
|
|
21
|
+
}
|
|
22
|
+
/** Compute the URL signature (base64url HMAC-SHA256) for a set of image params. Guards (and so does
|
|
23
|
+
* the verify path, which calls through here) against a sub-256-bit secret. */
|
|
24
|
+
export declare function signImageParams(secret: string, parts: ImageSignatureParts): string;
|
|
25
|
+
/**
|
|
26
|
+
* Verify a request's signature against `secret` (constant-time) and, when an `exp` is present, that it
|
|
27
|
+
* hasn't passed `nowSeconds`. Returns `false` for a missing/forged/expired signature.
|
|
28
|
+
*/
|
|
29
|
+
export declare function verifyImageParams(secret: string, parts: ImageSignatureParts, signature: string, nowSeconds: number): boolean;
|
|
30
|
+
//# sourceMappingURL=sign.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sign.d.ts","sourceRoot":"","sources":["../src/sign.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAgHH;sEACsE;AACtE,wBAAgB,aAAa,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM,CAElE;AAiBD;;gCAEgC;AAChC,MAAM,WAAW,mBAAmB;IAClC,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAA;IACpB,QAAQ,CAAC,CAAC,EAAE,MAAM,CAAA;IAGlB,QAAQ,CAAC,CAAC,CAAC,EAAE,MAAM,GAAG,SAAS,CAAA;IAC/B,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,GAAG,SAAS,CAAA;CAClC;AASD;8EAC8E;AAC9E,wBAAgB,eAAe,CAAC,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,mBAAmB,GAAG,MAAM,CAOlF;AAED;;;GAGG;AACH,wBAAgB,iBAAiB,CAC/B,MAAM,EAAE,MAAM,EACd,KAAK,EAAE,mBAAmB,EAC1B,SAAS,EAAE,MAAM,EACjB,UAAU,EAAE,MAAM,GACjB,OAAO,CAMT"}
|
package/dist/sign.js
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Signed image URLs — a portable, **synchronous** HMAC-SHA256 so the (sync) `selfHostedLoader` can sign
|
|
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 —
|
|
5
|
+
* shutting down resize-bombing (width/quality enumeration) and locking the endpoint to your own images.
|
|
6
|
+
*
|
|
7
|
+
* Pure JS, dependency-free, KAT-tested against the SHA-256 / RFC 4231 HMAC vectors. The secret never
|
|
8
|
+
* leaves the bytes you pass in.
|
|
9
|
+
*/
|
|
10
|
+
// FIPS 180-4 SHA-256 round constants.
|
|
11
|
+
// biome-ignore format: the 64 constants read best as an 8×8 grid.
|
|
12
|
+
const K = new Uint32Array([
|
|
13
|
+
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
|
|
14
|
+
0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
|
|
15
|
+
0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
|
|
16
|
+
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
|
|
17
|
+
0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
|
|
18
|
+
0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
|
|
19
|
+
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
|
|
20
|
+
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
|
|
21
|
+
]);
|
|
22
|
+
const rotr = (x, n) => ((x >>> n) | (x << (32 - n))) >>> 0;
|
|
23
|
+
/** SHA-256 of a byte array → 32-byte digest. */
|
|
24
|
+
function sha256(data) {
|
|
25
|
+
const len = data.length;
|
|
26
|
+
const bitLen = len * 8;
|
|
27
|
+
// Pad: 0x80, then zeros, so the total is ≡ 56 (mod 64), then the 64-bit big-endian bit length.
|
|
28
|
+
const padded = (56 - ((len + 1) % 64) + 64) % 64;
|
|
29
|
+
const total = len + 1 + padded + 8;
|
|
30
|
+
const msg = new Uint8Array(total);
|
|
31
|
+
msg.set(data);
|
|
32
|
+
msg[len] = 0x80;
|
|
33
|
+
const dv = new DataView(msg.buffer);
|
|
34
|
+
dv.setUint32(total - 8, Math.floor(bitLen / 0x1_0000_0000)); // high word (0 for our sizes)
|
|
35
|
+
dv.setUint32(total - 4, bitLen >>> 0);
|
|
36
|
+
const h = new Uint32Array([
|
|
37
|
+
0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19,
|
|
38
|
+
]);
|
|
39
|
+
const w = new Uint32Array(64);
|
|
40
|
+
for (let off = 0; off < total; off += 64) {
|
|
41
|
+
for (let i = 0; i < 16; i++)
|
|
42
|
+
w[i] = dv.getUint32(off + i * 4);
|
|
43
|
+
for (let i = 16; i < 64; i++) {
|
|
44
|
+
const a = w[i - 15];
|
|
45
|
+
const b = w[i - 2];
|
|
46
|
+
const s0 = (rotr(a, 7) ^ rotr(a, 18) ^ (a >>> 3)) >>> 0;
|
|
47
|
+
const s1 = (rotr(b, 17) ^ rotr(b, 19) ^ (b >>> 10)) >>> 0;
|
|
48
|
+
w[i] = (w[i - 16] + s0 + w[i - 7] + s1) >>> 0;
|
|
49
|
+
}
|
|
50
|
+
let a = h[0];
|
|
51
|
+
let b = h[1];
|
|
52
|
+
let c = h[2];
|
|
53
|
+
let d = h[3];
|
|
54
|
+
let e = h[4];
|
|
55
|
+
let f = h[5];
|
|
56
|
+
let g = h[6];
|
|
57
|
+
let hh = h[7];
|
|
58
|
+
for (let i = 0; i < 64; i++) {
|
|
59
|
+
const s1 = (rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25)) >>> 0;
|
|
60
|
+
const ch = ((e & f) ^ (~e & g)) >>> 0;
|
|
61
|
+
const t1 = (hh + s1 + ch + K[i] + w[i]) >>> 0;
|
|
62
|
+
const s0 = (rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22)) >>> 0;
|
|
63
|
+
const maj = ((a & b) ^ (a & c) ^ (b & c)) >>> 0;
|
|
64
|
+
const t2 = (s0 + maj) >>> 0;
|
|
65
|
+
hh = g;
|
|
66
|
+
g = f;
|
|
67
|
+
f = e;
|
|
68
|
+
e = (d + t1) >>> 0;
|
|
69
|
+
d = c;
|
|
70
|
+
c = b;
|
|
71
|
+
b = a;
|
|
72
|
+
a = (t1 + t2) >>> 0;
|
|
73
|
+
}
|
|
74
|
+
h[0] = (h[0] + a) >>> 0;
|
|
75
|
+
h[1] = (h[1] + b) >>> 0;
|
|
76
|
+
h[2] = (h[2] + c) >>> 0;
|
|
77
|
+
h[3] = (h[3] + d) >>> 0;
|
|
78
|
+
h[4] = (h[4] + e) >>> 0;
|
|
79
|
+
h[5] = (h[5] + f) >>> 0;
|
|
80
|
+
h[6] = (h[6] + g) >>> 0;
|
|
81
|
+
h[7] = (h[7] + hh) >>> 0;
|
|
82
|
+
}
|
|
83
|
+
const out = new Uint8Array(32);
|
|
84
|
+
const odv = new DataView(out.buffer);
|
|
85
|
+
for (let i = 0; i < 8; i++)
|
|
86
|
+
odv.setUint32(i * 4, h[i]);
|
|
87
|
+
return out;
|
|
88
|
+
}
|
|
89
|
+
const BLOCK = 64;
|
|
90
|
+
/** HMAC-SHA256 (RFC 2104) of `message` under `key` → 32-byte tag. */
|
|
91
|
+
function hmacSha256(key, message) {
|
|
92
|
+
let k = key.length > BLOCK ? sha256(key) : key;
|
|
93
|
+
if (k.length < BLOCK) {
|
|
94
|
+
const padded = new Uint8Array(BLOCK);
|
|
95
|
+
padded.set(k);
|
|
96
|
+
k = padded;
|
|
97
|
+
}
|
|
98
|
+
const inner = new Uint8Array(BLOCK + message.length);
|
|
99
|
+
const outer = new Uint8Array(BLOCK + 32);
|
|
100
|
+
for (let i = 0; i < BLOCK; i++) {
|
|
101
|
+
inner[i] = k[i] ^ 0x36;
|
|
102
|
+
outer[i] = k[i] ^ 0x5c;
|
|
103
|
+
}
|
|
104
|
+
inner.set(message, BLOCK);
|
|
105
|
+
outer.set(sha256(inner), BLOCK);
|
|
106
|
+
return sha256(outer);
|
|
107
|
+
}
|
|
108
|
+
const encoder = new TextEncoder();
|
|
109
|
+
const hex = (bytes) => {
|
|
110
|
+
let s = "";
|
|
111
|
+
for (const b of bytes)
|
|
112
|
+
s += b.toString(16).padStart(2, "0");
|
|
113
|
+
return s;
|
|
114
|
+
};
|
|
115
|
+
/** HMAC-SHA256 of `message` under `key` (both UTF-8 strings) → lowercase hex. The primitive behind URL
|
|
116
|
+
* signing; exported for tests (RFC 4231 vectors) and advanced use. */
|
|
117
|
+
export function hmacSha256Hex(key, message) {
|
|
118
|
+
return hex(hmacSha256(encoder.encode(key), encoder.encode(message)));
|
|
119
|
+
}
|
|
120
|
+
/** base64url (RFC 4648 §5, unpadded) of a byte array — URL-safe, compact (43 chars for a 32-byte tag). */
|
|
121
|
+
function base64url(bytes) {
|
|
122
|
+
let binary = "";
|
|
123
|
+
for (const b of bytes)
|
|
124
|
+
binary += String.fromCharCode(b);
|
|
125
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
126
|
+
}
|
|
127
|
+
/** Constant-time string compare — length-independent of where the first difference is. */
|
|
128
|
+
function safeEqual(a, b) {
|
|
129
|
+
if (a.length !== b.length)
|
|
130
|
+
return false;
|
|
131
|
+
let diff = 0;
|
|
132
|
+
for (let i = 0; i < a.length; i++)
|
|
133
|
+
diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
134
|
+
return diff === 0;
|
|
135
|
+
}
|
|
136
|
+
const canonical = (p) => `${p.src}\n${p.w}\n${p.q ?? ""}\n${p.exp ?? ""}`;
|
|
137
|
+
// 256-bit secret floor — mirrors @nifrajs/core's HMAC guard. This package is dependency-free (ships
|
|
138
|
+
// its own SHA-256), so the constant is inlined rather than imported.
|
|
139
|
+
const MIN_SECRET_BYTES = 32;
|
|
140
|
+
/** Compute the URL signature (base64url HMAC-SHA256) for a set of image params. Guards (and so does
|
|
141
|
+
* the verify path, which calls through here) against a sub-256-bit secret. */
|
|
142
|
+
export function signImageParams(secret, parts) {
|
|
143
|
+
if (encoder.encode(secret).length < MIN_SECRET_BYTES) {
|
|
144
|
+
throw new Error(`[nifra/image] signing secret must be at least ${MIN_SECRET_BYTES} bytes (256-bit). Generate one with: openssl rand -base64 32`);
|
|
145
|
+
}
|
|
146
|
+
return base64url(hmacSha256(encoder.encode(secret), encoder.encode(canonical(parts))));
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Verify a request's signature against `secret` (constant-time) and, when an `exp` is present, that it
|
|
150
|
+
* hasn't passed `nowSeconds`. Returns `false` for a missing/forged/expired signature.
|
|
151
|
+
*/
|
|
152
|
+
export function verifyImageParams(secret, parts, signature, nowSeconds) {
|
|
153
|
+
if (parts.exp !== undefined) {
|
|
154
|
+
if (!/^\d+$/.test(parts.exp))
|
|
155
|
+
return false;
|
|
156
|
+
if (Number(parts.exp) < nowSeconds)
|
|
157
|
+
return false; // expired
|
|
158
|
+
}
|
|
159
|
+
return safeEqual(signImageParams(secret, parts), signature);
|
|
160
|
+
}
|
|
161
|
+
//# sourceMappingURL=sign.js.map
|
package/dist/sign.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sign.js","sourceRoot":"","sources":["../src/sign.ts"],"names":[],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@nifrajs/image",
|
|
3
|
+
"version": "0.1.0-alpha.1",
|
|
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
|
+
"keywords": [
|
|
6
|
+
"bun",
|
|
7
|
+
"typescript",
|
|
8
|
+
"nifra",
|
|
9
|
+
"web-framework",
|
|
10
|
+
"full-stack",
|
|
11
|
+
"edge",
|
|
12
|
+
"ssr"
|
|
13
|
+
],
|
|
14
|
+
"type": "module",
|
|
15
|
+
"main": "./dist/index.js",
|
|
16
|
+
"module": "./dist/index.js",
|
|
17
|
+
"types": "./dist/index.d.ts",
|
|
18
|
+
"exports": {
|
|
19
|
+
".": {
|
|
20
|
+
"bun": "./src/index.ts",
|
|
21
|
+
"types": "./dist/index.d.ts",
|
|
22
|
+
"default": "./dist/index.js"
|
|
23
|
+
},
|
|
24
|
+
"./server": {
|
|
25
|
+
"bun": "./src/server.ts",
|
|
26
|
+
"types": "./dist/server.d.ts",
|
|
27
|
+
"default": "./dist/server.js"
|
|
28
|
+
},
|
|
29
|
+
"./backends": {
|
|
30
|
+
"bun": "./src/backend.ts",
|
|
31
|
+
"types": "./dist/backend.d.ts",
|
|
32
|
+
"default": "./dist/backend.js"
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"files": [
|
|
36
|
+
"dist",
|
|
37
|
+
"src"
|
|
38
|
+
],
|
|
39
|
+
"sideEffects": false,
|
|
40
|
+
"scripts": {
|
|
41
|
+
"build": "tsc -p tsconfig.build.json && bun run ../../scripts/fix-dts.ts dist"
|
|
42
|
+
},
|
|
43
|
+
"license": "MIT",
|
|
44
|
+
"publishConfig": {
|
|
45
|
+
"access": "public"
|
|
46
|
+
},
|
|
47
|
+
"repository": {
|
|
48
|
+
"type": "git",
|
|
49
|
+
"url": "git+https://github.com/nifrajs/nifra.git",
|
|
50
|
+
"directory": "packages/image"
|
|
51
|
+
},
|
|
52
|
+
"bugs": {
|
|
53
|
+
"url": "https://github.com/nifrajs/nifra/issues"
|
|
54
|
+
},
|
|
55
|
+
"homepage": "https://github.com/nifrajs/nifra#readme"
|
|
56
|
+
}
|
package/src/backend.ts
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@nifrajs/image/backends` — the codec seam + the official {@link ImageBackend} implementations, kept
|
|
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 — it touches the
|
|
5
|
+
* filesystem) consumes this seam and owns all request-level security; a backend only
|
|
6
|
+
* decodes/resizes/encodes and translates codec failures into {@link ImageProcessingError}.
|
|
7
|
+
*
|
|
8
|
+
* Pick a backend for your runtime:
|
|
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
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { imageDimensions } from "./dimensions.ts"
|
|
15
|
+
|
|
16
|
+
/** Output formats nifra's endpoint can emit. AVIF is intentionally excluded — `Bun.Image` reports
|
|
17
|
+
* `ERR_IMAGE_FORMAT_UNSUPPORTED` for AVIF encode on common platforms, so offering it would 500. */
|
|
18
|
+
export type OutputFormat = "webp" | "jpeg" | "png"
|
|
19
|
+
|
|
20
|
+
export const CONTENT_TYPE: Record<OutputFormat, string> = {
|
|
21
|
+
webp: "image/webp",
|
|
22
|
+
jpeg: "image/jpeg",
|
|
23
|
+
png: "image/png",
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Header-only probe of a source image: intrinsic dimensions + decoded format. Must be cheap (no full
|
|
27
|
+
* decode) — it gates the decompression-bomb and no-upscale checks before the expensive resize. */
|
|
28
|
+
export interface ImageProbe {
|
|
29
|
+
readonly width: number
|
|
30
|
+
readonly height: number
|
|
31
|
+
/** Lowercased source format as the codec sees it (`"png"`, `"jpeg"`, `"webp"`, `"gif"`, …). */
|
|
32
|
+
readonly format: string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface ResizeInput {
|
|
36
|
+
readonly bytes: Uint8Array
|
|
37
|
+
/** Target width in px — already clamped to `[1, maxWidth]` **and** to the source's intrinsic width
|
|
38
|
+
* (the handler never asks a backend to upscale). Aspect ratio is preserved. */
|
|
39
|
+
readonly width: number
|
|
40
|
+
/** Encoder quality `1..100` (ignored for lossless `png`). */
|
|
41
|
+
readonly quality: number
|
|
42
|
+
readonly format: OutputFormat
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface ResizeOutput {
|
|
46
|
+
readonly bytes: Uint8Array
|
|
47
|
+
readonly contentType: string
|
|
48
|
+
readonly format: OutputFormat
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The codec seam. The handler owns all request-level security (validation, SSRF, byte/pixel caps,
|
|
53
|
+
* concurrency, caching); a backend only decodes/resizes/encodes. Backends MUST translate codec failures
|
|
54
|
+
* into {@link ImageProcessingError} so the handler can map them to stable HTTP statuses.
|
|
55
|
+
*/
|
|
56
|
+
export interface ImageBackend {
|
|
57
|
+
/** Cheap, header-only metadata read. Throws {@link ImageProcessingError} (`decode` / `too_large`). */
|
|
58
|
+
probe(bytes: Uint8Array): Promise<ImageProbe>
|
|
59
|
+
/** Decode → resize to `input.width` (aspect-preserving) → encode to `input.format`. */
|
|
60
|
+
transform(input: ResizeInput): Promise<ResizeOutput>
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Normalized, backend-agnostic processing failure. Lets the handler map codec errors to HTTP status
|
|
64
|
+
* without coupling to any one codec's error codes. */
|
|
65
|
+
export class ImageProcessingError extends Error {
|
|
66
|
+
constructor(
|
|
67
|
+
readonly kind: "decode" | "too_large" | "unsupported",
|
|
68
|
+
message: string,
|
|
69
|
+
) {
|
|
70
|
+
super(message)
|
|
71
|
+
this.name = "ImageProcessingError"
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// --- Bun.Image backend -------------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
/** Minimal structural type for the `Bun.Image` surface this backend uses (avoids a hard Bun type dep). */
|
|
78
|
+
interface BunImageCtor {
|
|
79
|
+
new (
|
|
80
|
+
input: Uint8Array,
|
|
81
|
+
): {
|
|
82
|
+
metadata(): Promise<{ width: number; height: number; format: string }>
|
|
83
|
+
resize(width: number): BunImageChain
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
interface BunImageChain {
|
|
87
|
+
webp(opts: { quality: number }): { bytes(): Promise<Uint8Array> }
|
|
88
|
+
jpeg(opts: { quality: number }): { bytes(): Promise<Uint8Array> }
|
|
89
|
+
png(): { bytes(): Promise<Uint8Array> }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* {@link ImageBackend} backed by `Bun.Image` (libjpeg-turbo / libspng / libwebp, decoded off the main
|
|
94
|
+
* thread). Requires the Bun runtime. The default backend of `createImageHandler`.
|
|
95
|
+
*/
|
|
96
|
+
export function bunImageBackend(): ImageBackend {
|
|
97
|
+
if (typeof (globalThis as { Bun?: unknown }).Bun === "undefined") {
|
|
98
|
+
throw new Error("[nifra/image] bunImageBackend() requires the Bun runtime")
|
|
99
|
+
}
|
|
100
|
+
const Image = (globalThis as unknown as { Bun: { Image: BunImageCtor } }).Bun.Image
|
|
101
|
+
return {
|
|
102
|
+
async probe(bytes) {
|
|
103
|
+
try {
|
|
104
|
+
const md = await new Image(bytes).metadata()
|
|
105
|
+
return { width: md.width, height: md.height, format: String(md.format).toLowerCase() }
|
|
106
|
+
} catch (err) {
|
|
107
|
+
throw toBunError(err)
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
async transform({ bytes, width, quality, format }) {
|
|
111
|
+
try {
|
|
112
|
+
const img = new Image(bytes).resize(width)
|
|
113
|
+
const encoded =
|
|
114
|
+
format === "webp"
|
|
115
|
+
? img.webp({ quality })
|
|
116
|
+
: format === "jpeg"
|
|
117
|
+
? img.jpeg({ quality })
|
|
118
|
+
: img.png()
|
|
119
|
+
const out = await encoded.bytes()
|
|
120
|
+
return { bytes: out, contentType: CONTENT_TYPE[format], format }
|
|
121
|
+
} catch (err) {
|
|
122
|
+
throw toBunError(err)
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Map a `Bun.Image` failure to a normalized {@link ImageProcessingError}. */
|
|
129
|
+
function toBunError(err: unknown): ImageProcessingError {
|
|
130
|
+
if (err instanceof ImageProcessingError) return err
|
|
131
|
+
const code = (err as { code?: string } | null)?.code ?? ""
|
|
132
|
+
if (code === "ERR_IMAGE_TOO_MANY_PIXELS") {
|
|
133
|
+
return new ImageProcessingError("too_large", "source image exceeds the codec pixel limit")
|
|
134
|
+
}
|
|
135
|
+
if (code === "ERR_IMAGE_FORMAT_UNSUPPORTED") {
|
|
136
|
+
return new ImageProcessingError("unsupported", "image format not supported by the codec")
|
|
137
|
+
}
|
|
138
|
+
return new ImageProcessingError("decode", "source is not a decodable image")
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// --- sharp backend (Node) ----------------------------------------------------------------------------
|
|
142
|
+
|
|
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 — pass your own `sharp` import. */
|
|
145
|
+
export type SharpLike = (input: Uint8Array) => SharpInstance
|
|
146
|
+
interface SharpInstance {
|
|
147
|
+
metadata(): Promise<{ width?: number; height?: number; format?: string }>
|
|
148
|
+
resize(options: { width: number; withoutEnlargement?: boolean }): SharpInstance
|
|
149
|
+
webp(options: { quality: number }): SharpInstance
|
|
150
|
+
jpeg(options: { quality: number }): SharpInstance
|
|
151
|
+
png(): SharpInstance
|
|
152
|
+
toBuffer(): Promise<Uint8Array>
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* {@link ImageBackend} backed by [sharp](https://sharp.pixelplumbing.com) (libvips) for Node servers.
|
|
157
|
+
* Pass your `sharp` import — `@nifrajs/image` never imports it, so it stays dependency-free and you control
|
|
158
|
+
* the version:
|
|
159
|
+
*
|
|
160
|
+
* ```ts
|
|
161
|
+
* import sharp from "sharp"
|
|
162
|
+
* createImageHandler({ backend: sharpImageBackend(sharp), root: "./public" })
|
|
163
|
+
* ```
|
|
164
|
+
*/
|
|
165
|
+
export function sharpImageBackend(sharp: SharpLike): ImageBackend {
|
|
166
|
+
return {
|
|
167
|
+
async probe(bytes) {
|
|
168
|
+
try {
|
|
169
|
+
const md = await sharp(bytes).metadata()
|
|
170
|
+
if (md.width === undefined || md.height === undefined) {
|
|
171
|
+
throw new ImageProcessingError("decode", "sharp could not read image dimensions")
|
|
172
|
+
}
|
|
173
|
+
return { width: md.width, height: md.height, format: String(md.format ?? "").toLowerCase() }
|
|
174
|
+
} catch (err) {
|
|
175
|
+
throw toSharpError(err)
|
|
176
|
+
}
|
|
177
|
+
},
|
|
178
|
+
async transform({ bytes, width, quality, format }) {
|
|
179
|
+
try {
|
|
180
|
+
// `withoutEnlargement` is belt-and-braces: the handler already clamps width ≤ intrinsic.
|
|
181
|
+
const pipeline = sharp(bytes).resize({ width, withoutEnlargement: true })
|
|
182
|
+
const encoded =
|
|
183
|
+
format === "webp"
|
|
184
|
+
? pipeline.webp({ quality })
|
|
185
|
+
: format === "jpeg"
|
|
186
|
+
? pipeline.jpeg({ quality })
|
|
187
|
+
: pipeline.png()
|
|
188
|
+
return { bytes: await encoded.toBuffer(), contentType: CONTENT_TYPE[format], format }
|
|
189
|
+
} catch (err) {
|
|
190
|
+
throw toSharpError(err)
|
|
191
|
+
}
|
|
192
|
+
},
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Map a sharp failure to a normalized {@link ImageProcessingError} by its message. */
|
|
197
|
+
function toSharpError(err: unknown): ImageProcessingError {
|
|
198
|
+
if (err instanceof ImageProcessingError) return err
|
|
199
|
+
const message = (err instanceof Error ? err.message : "").toLowerCase()
|
|
200
|
+
if (message.includes("pixel limit") || message.includes("too large")) {
|
|
201
|
+
return new ImageProcessingError("too_large", "source image exceeds sharp's pixel limit")
|
|
202
|
+
}
|
|
203
|
+
if (message.includes("unsupported image format") || message.includes("bad seek")) {
|
|
204
|
+
return new ImageProcessingError("decode", "sharp could not decode the source image")
|
|
205
|
+
}
|
|
206
|
+
return new ImageProcessingError("decode", "sharp failed to process the image")
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// --- WASM backend (edge-portable) --------------------------------------------------------------------
|
|
210
|
+
|
|
211
|
+
/** A decoded image: RGBA pixels + dimensions — the lingua franca of WASM codecs (jSquash, Photon, …). */
|
|
212
|
+
export interface DecodedImage {
|
|
213
|
+
/** RGBA bytes, length `width * height * 4`. */
|
|
214
|
+
readonly data: Uint8Array
|
|
215
|
+
readonly width: number
|
|
216
|
+
readonly height: number
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Pluggable WASM codec set — decode/resize/encode. Declared structurally so `@nifrajs/image` depends on no
|
|
221
|
+
* WASM library; wire your own (jSquash is the common pure-WASM, edge-safe choice). The handler probes
|
|
222
|
+
* dimensions from the source header (bomb-safe), so `decode` runs only inside `transform`.
|
|
223
|
+
*/
|
|
224
|
+
export interface WasmImageCodecs {
|
|
225
|
+
/** Decode any supported encoded image → RGBA. Throw on undecodable input. */
|
|
226
|
+
decode(bytes: Uint8Array): Promise<DecodedImage> | DecodedImage
|
|
227
|
+
/** Resize RGBA to the target dimensions (the handler keeps the aspect ratio). */
|
|
228
|
+
resize(image: DecodedImage, width: number, height: number): Promise<DecodedImage> | DecodedImage
|
|
229
|
+
/** Encode RGBA → the target format's bytes. */
|
|
230
|
+
encode(
|
|
231
|
+
image: DecodedImage,
|
|
232
|
+
format: OutputFormat,
|
|
233
|
+
quality: number,
|
|
234
|
+
): Promise<Uint8Array> | Uint8Array
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* {@link ImageBackend} backed by injected WASM codecs — the only backend that runs on the **edge**
|
|
239
|
+
* (Workers / Vercel-Edge / Deno-Deploy), where neither `Bun.Image` nor sharp exists. `probe` reads the
|
|
240
|
+
* source header via nifra's dependency-free reader (so decompression bombs are rejected before any
|
|
241
|
+
* decode); `transform` decodes → resizes (aspect-preserving) → encodes through your codecs.
|
|
242
|
+
*
|
|
243
|
+
* ```ts
|
|
244
|
+
* import decode from "@jsquash/jpeg/decode"; import resize from "@jsquash/resize"
|
|
245
|
+
* import encodeWebp from "@jsquash/webp/encode" // …+ png/jpeg encoders
|
|
246
|
+
* const backend = wasmImageBackend({ decode, resize: (img, w, h) => resize(img, { width: w, height: h }), encode })
|
|
247
|
+
* ```
|
|
248
|
+
*/
|
|
249
|
+
export function wasmImageBackend(codecs: WasmImageCodecs): ImageBackend {
|
|
250
|
+
return {
|
|
251
|
+
async probe(bytes) {
|
|
252
|
+
const info = imageDimensions(bytes) // header-only — never allocates a decoded buffer
|
|
253
|
+
if (info === null) {
|
|
254
|
+
throw new ImageProcessingError("decode", "unrecognized image header")
|
|
255
|
+
}
|
|
256
|
+
return { width: info.width, height: info.height, format: info.format }
|
|
257
|
+
},
|
|
258
|
+
async transform({ bytes, width, quality, format }) {
|
|
259
|
+
try {
|
|
260
|
+
const decoded = await codecs.decode(bytes)
|
|
261
|
+
// Aspect-preserving: the handler hands a width already clamped to the intrinsic width.
|
|
262
|
+
const height = Math.max(1, Math.round((decoded.height * width) / decoded.width))
|
|
263
|
+
const resized =
|
|
264
|
+
width === decoded.width ? decoded : await codecs.resize(decoded, width, height)
|
|
265
|
+
const out = await codecs.encode(resized, format, quality)
|
|
266
|
+
return { bytes: out, contentType: CONTENT_TYPE[format], format }
|
|
267
|
+
} catch (err) {
|
|
268
|
+
if (err instanceof ImageProcessingError) throw err
|
|
269
|
+
throw new ImageProcessingError("decode", "wasm codec failed to process the image")
|
|
270
|
+
}
|
|
271
|
+
},
|
|
272
|
+
}
|
|
273
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read an image's intrinsic dimensions from its file **header**, in pure JS — no decode, no codec, no
|
|
3
|
+
* dependency. Supports PNG, JPEG, GIF, and WebP (VP8/VP8L/VP8X). Used to give `<Image>` CLS-safe
|
|
4
|
+
* `width`/`height` (build-time tooling can pre-read them into a manifest).
|
|
5
|
+
*/
|
|
6
|
+
export type ImageFormat = "png" | "jpeg" | "gif" | "webp"
|
|
7
|
+
|
|
8
|
+
export interface ImageInfo {
|
|
9
|
+
readonly width: number
|
|
10
|
+
readonly height: number
|
|
11
|
+
readonly format: ImageFormat
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const png = (b: Uint8Array, dv: DataView): ImageInfo | null => {
|
|
15
|
+
// 8-byte signature, then IHDR (length+type), then width/height as big-endian uint32 @16/@20.
|
|
16
|
+
if (b.length < 24 || b[0] !== 0x89 || b[1] !== 0x50 || b[2] !== 0x4e || b[3] !== 0x47) return null
|
|
17
|
+
return { width: dv.getUint32(16), height: dv.getUint32(20), format: "png" }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const gif = (b: Uint8Array, dv: DataView): ImageInfo | null => {
|
|
21
|
+
// 'GIF', then the logical-screen width/height as little-endian uint16 @6/@8.
|
|
22
|
+
if (b.length < 10 || b[0] !== 0x47 || b[1] !== 0x49 || b[2] !== 0x46) return null
|
|
23
|
+
return { width: dv.getUint16(6, true), height: dv.getUint16(8, true), format: "gif" }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const jpeg = (b: Uint8Array, dv: DataView): ImageInfo | null => {
|
|
27
|
+
if (b.length < 4 || b[0] !== 0xff || b[1] !== 0xd8) return null
|
|
28
|
+
let offset = 2
|
|
29
|
+
while (offset + 9 < b.length) {
|
|
30
|
+
if (dv.getUint8(offset) !== 0xff) return null // not aligned on a marker → malformed
|
|
31
|
+
const marker = dv.getUint8(offset + 1)
|
|
32
|
+
// SOF0..SOF15 carry the frame's height/width — except DHT(c4)/DNL(c8)/DAC(cc).
|
|
33
|
+
if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
|
|
34
|
+
return { height: dv.getUint16(offset + 5), width: dv.getUint16(offset + 7), format: "jpeg" }
|
|
35
|
+
}
|
|
36
|
+
offset += 2 + dv.getUint16(offset + 2) // skip this segment (2-byte marker + segment length)
|
|
37
|
+
}
|
|
38
|
+
return null
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const webp = (b: Uint8Array, dv: DataView): ImageInfo | null => {
|
|
42
|
+
// 'RIFF' .... 'WEBP' then a VP8 / VP8L / VP8X chunk.
|
|
43
|
+
if (b.length < 30 || b[0] !== 0x52 || b[1] !== 0x49 || b[8] !== 0x57 || b[9] !== 0x45) return null
|
|
44
|
+
const chunk = String.fromCharCode(
|
|
45
|
+
b[12] as number,
|
|
46
|
+
b[13] as number,
|
|
47
|
+
b[14] as number,
|
|
48
|
+
b[15] as number,
|
|
49
|
+
)
|
|
50
|
+
if (chunk === "VP8 ") {
|
|
51
|
+
// lossy: 14-bit width/height (little-endian) at @26/@28, masked to 14 bits.
|
|
52
|
+
return {
|
|
53
|
+
width: dv.getUint16(26, true) & 0x3fff,
|
|
54
|
+
height: dv.getUint16(28, true) & 0x3fff,
|
|
55
|
+
format: "webp",
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
if (chunk === "VP8L") {
|
|
59
|
+
// lossless: after the 0x2f signature @20, 14-bit (width-1) then 14-bit (height-1), bit-packed LE.
|
|
60
|
+
if (b[20] !== 0x2f) return null
|
|
61
|
+
const bits = dv.getUint32(21, true)
|
|
62
|
+
return { width: (bits & 0x3fff) + 1, height: ((bits >> 14) & 0x3fff) + 1, format: "webp" }
|
|
63
|
+
}
|
|
64
|
+
if (chunk === "VP8X") {
|
|
65
|
+
// extended: 24-bit (width-1) then 24-bit (height-1), little-endian, at @24/@27.
|
|
66
|
+
const w = (b[24] as number) | ((b[25] as number) << 8) | ((b[26] as number) << 16)
|
|
67
|
+
const h = (b[27] as number) | ((b[28] as number) << 8) | ((b[29] as number) << 16)
|
|
68
|
+
return { width: w + 1, height: h + 1, format: "webp" }
|
|
69
|
+
}
|
|
70
|
+
return null
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Parse intrinsic dimensions + format from image header bytes, or `null` if unrecognized/too short. */
|
|
74
|
+
export function imageDimensions(bytes: Uint8Array): ImageInfo | null {
|
|
75
|
+
if (bytes.length < 4) return null
|
|
76
|
+
const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
|
|
77
|
+
return png(bytes, dv) ?? gif(bytes, dv) ?? jpeg(bytes, dv) ?? webp(bytes, dv)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Read just the leading bytes of an image file (via the platform `Bun.file`/`fetch` blob) and parse its
|
|
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 — enough for any header).
|
|
84
|
+
*/
|
|
85
|
+
export async function readImageDimensions(
|
|
86
|
+
source: { arrayBuffer(): Promise<ArrayBuffer>; stream?: () => ReadableStream<Uint8Array> },
|
|
87
|
+
maxBytes = 65_536,
|
|
88
|
+
): Promise<ImageInfo | null> {
|
|
89
|
+
if (!Number.isSafeInteger(maxBytes) || maxBytes < 1) {
|
|
90
|
+
throw new Error("[nifra/image] readImageDimensions: maxBytes must be a positive safe integer")
|
|
91
|
+
}
|
|
92
|
+
if (source.stream !== undefined) {
|
|
93
|
+
const reader = source.stream().getReader()
|
|
94
|
+
const chunks: Uint8Array[] = []
|
|
95
|
+
let total = 0
|
|
96
|
+
let reachedEof = false
|
|
97
|
+
try {
|
|
98
|
+
while (total < maxBytes) {
|
|
99
|
+
const { done, value } = await reader.read()
|
|
100
|
+
if (done) {
|
|
101
|
+
reachedEof = true
|
|
102
|
+
break
|
|
103
|
+
}
|
|
104
|
+
const remaining = maxBytes - total
|
|
105
|
+
const chunk = value.byteLength > remaining ? value.subarray(0, remaining) : value
|
|
106
|
+
chunks.push(chunk)
|
|
107
|
+
total += chunk.byteLength
|
|
108
|
+
if (value.byteLength > remaining) {
|
|
109
|
+
await reader.cancel()
|
|
110
|
+
reachedEof = true
|
|
111
|
+
break
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
if (!reachedEof) await reader.cancel()
|
|
115
|
+
} finally {
|
|
116
|
+
reader.releaseLock()
|
|
117
|
+
}
|
|
118
|
+
const bytes = new Uint8Array(total)
|
|
119
|
+
let offset = 0
|
|
120
|
+
for (const chunk of chunks) {
|
|
121
|
+
bytes.set(chunk, offset)
|
|
122
|
+
offset += chunk.byteLength
|
|
123
|
+
}
|
|
124
|
+
return imageDimensions(bytes)
|
|
125
|
+
}
|
|
126
|
+
const buf = await source.arrayBuffer()
|
|
127
|
+
const bytes = new Uint8Array(buf)
|
|
128
|
+
return imageDimensions(bytes.length > maxBytes ? bytes.subarray(0, maxBytes) : bytes)
|
|
129
|
+
}
|