castrum 0.9.0 → 0.9.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.
Files changed (102) hide show
  1. package/README.md +153 -630
  2. package/castrum.linux-x64-gnu.node +0 -0
  3. package/dist/index.d.ts +1374 -1772
  4. package/dist/index.js +5570 -4169
  5. package/docs/API.md +337 -0
  6. package/docs/ARCHITECTURE.md +106 -14
  7. package/docs/BENCHMARKS.md +83 -47
  8. package/docs/CASE_STUDY.md +240 -0
  9. package/docs/ENVIRONMENT.md +8 -6
  10. package/docs/FFI_BUN_GUIDE.md +327 -0
  11. package/docs/GETTING_STARTED.md +15 -32
  12. package/docs/INGRESS-ROUTER.md +81 -0
  13. package/docs/INGRESS.md +5 -4
  14. package/docs/NATIVE-ROUTE.md +53 -0
  15. package/docs/REPO_MAP.md +46 -26
  16. package/docs/adr/0003-bun-builtins-delegation.md +6 -5
  17. package/docs/bun-builtins-decision-matrix.md +25 -18
  18. package/index.ts +9 -16
  19. package/package.json +36 -24
  20. package/src/ingress/body.ts +167 -27
  21. package/src/ingress/handlers.ts +91 -15
  22. package/src/ingress/health.ts +1 -0
  23. package/src/ingress/index.ts +39 -27
  24. package/src/ingress/packing/gather-raw-headers.ts +85 -21
  25. package/src/ingress/packing/header-packing.ts +5 -5
  26. package/src/ingress/packing/input-packer.ts +17 -3
  27. package/src/ingress/packing/select-headers.ts +12 -6
  28. package/src/ingress/router.ts +237 -0
  29. package/src/ingress/routes/common.ts +42 -1
  30. package/src/ingress/routes/fallback.ts +2 -2
  31. package/src/ingress/routes/head.ts +3 -13
  32. package/src/ingress/routes/index.ts +5 -4
  33. package/src/ingress/routes/json-write.ts +7 -31
  34. package/src/ingress/routes/options.ts +2 -12
  35. package/src/ingress/routes/read.ts +3 -13
  36. package/src/ingress/routes/responder.ts +159 -0
  37. package/src/ingress/server-node.ts +6 -6
  38. package/src/ingress/server.ts +45 -13
  39. package/src/ingress/types.ts +35 -1
  40. package/src/integration/pipeline.ts +3 -0
  41. package/src/integration/streaming.ts +2 -3
  42. package/src/integration/websocket.ts +4 -3
  43. package/src/loader/batch.ts +4 -0
  44. package/src/loader/cost.ts +1 -0
  45. package/src/loader/index.ts +43 -228
  46. package/src/loader/state.ts +84 -0
  47. package/src/loader/types.ts +145 -0
  48. package/src/native/ffi/constants.ts +58 -0
  49. package/src/native/ffi/selftest.ts +729 -0
  50. package/src/native/ffi/types.ts +474 -0
  51. package/src/native/ffi.ts +1063 -829
  52. package/src/native/loader.ts +52 -6
  53. package/src/native/types/instances.ts +274 -0
  54. package/src/native/types.ts +41 -238
  55. package/src/runtime/builtins.ts +131 -0
  56. package/src/runtime/codec.ts +147 -0
  57. package/src/runtime/detect.ts +36 -0
  58. package/src/runtime/index.ts +28 -0
  59. package/src/runtime/native.ts +35 -0
  60. package/src/runtime/server.ts +31 -0
  61. package/src/runtime/transport.ts +64 -0
  62. package/src/runtime/types.ts +173 -0
  63. package/src/runtime/uuid.ts +45 -0
  64. package/src/runtime/websocket.ts +30 -0
  65. package/src/rust-ffi/addon.ts +1 -1
  66. package/src/rust-ffi/batch/build.ts +155 -0
  67. package/src/rust-ffi/batch/index.ts +8 -0
  68. package/src/rust-ffi/batch/types.ts +92 -0
  69. package/src/rust-ffi/client.ts +5 -6
  70. package/src/rust-ffi/context.ts +39 -10
  71. package/src/rust-ffi/index.ts +28 -17
  72. package/src/rust-ffi/into.ts +21 -0
  73. package/src/rust-ffi/options.ts +1 -1
  74. package/src/rust-ffi/packed.ts +2 -2
  75. package/src/rust-ffi/proven.ts +16 -30
  76. package/src/rust-ffi/scalar/crypto.ts +188 -107
  77. package/src/rust-ffi/scalar/factories.ts +253 -27
  78. package/src/rust-ffi/scalar/hashing.ts +43 -41
  79. package/src/rust-ffi/scalar/http.ts +208 -77
  80. package/src/rust-ffi/scalar/index.ts +3 -3
  81. package/src/rust-ffi/scalar/interface.ts +88 -213
  82. package/src/rust-ffi/scalar/json-packed.ts +175 -0
  83. package/src/rust-ffi/scalar/json.ts +28 -12
  84. package/src/rust-ffi/scalar/payload.ts +159 -42
  85. package/src/rust-ffi/text.ts +31 -5
  86. package/src/selection.ts +27 -31
  87. package/src/shared/adaptive.ts +1 -2
  88. package/src/shared/buffer-pool.ts +72 -17
  89. package/src/shared/bytes.ts +75 -6
  90. package/src/shared/codec.ts +78 -0
  91. package/src/shared/env.ts +4 -4
  92. package/src/shared/log.ts +1 -0
  93. package/src/shared/packed/index.ts +10 -0
  94. package/src/shared/packed/parsers.ts +31 -0
  95. package/src/shared/packed/schema.ts +23 -0
  96. package/src/shared/{packed.ts → packed/wire.ts} +31 -39
  97. package/src/shared/proven.ts +189 -496
  98. package/src/shared/runtime.ts +20 -25
  99. package/src/shared/uuid.ts +12 -28
  100. package/src/data/json-rows.ts +0 -33
  101. package/src/rust-ffi/batch.ts +0 -431
  102. package/src/shared/bench-classify.ts +0 -209
package/src/native/ffi.ts CHANGED
@@ -1,271 +1,82 @@
1
- // src/native/ffi.ts — Bun-only C-ABI fast path via `bun:ffi`.
1
+ // src/native/ffi.ts — Bun-only C-ABI fast path via `bun:ffi` (transport core).
2
2
  //
3
3
  // Bun JIT-compiles `bun:ffi` calls down to direct native calls (~10-20ns
4
4
  // crossing), versus ~100-350ns for a Node-API call. The same cdylib that Node
5
5
  // loads through napi-rs ALSO exports `extern "C"` symbols (`rust/ffi.rs`), so
6
- // under Bun we can `dlopen` it and call the hot scalar functions directly —
7
- // skipping the N-API marshaling that dominates the sub-µs operations.
6
+ // under Bun we can `dlopen` it and call the hot scalar functions directly.
7
+ //
8
+ // Structure: this file is the transport CORE (dlopen + symbol binding +
9
+ // per-call wrappers). The pure pieces live beside it: `ffi/types.ts` (BunFFI /
10
+ // FfiMode / Raw signatures), `ffi/constants.ts` (caps + sizing + self-test
11
+ // vectors), `ffi/selftest.ts` (the bind-time self-test). This file re-exports
12
+ // `getBunFFI` + `BunFFI` so existing importers keep working unchanged.
8
13
  //
9
14
  // Safety / correctness strategy:
10
15
  // - Lazily bound (no addon/ffi work until first use).
11
- // - A one-time SELF-TEST runs at bind time: every bound function is checked
12
- // against known-good vectors. If ANY check fails (ABI mismatch, platform
13
- // quirk, a future Bun regression), the whole ffi layer is disabled and the
14
- // caller falls back to the napi addon — the public API never sees a wrong
15
- // result. `bun:ffi` is experimental; this is the safety net.
16
- // - Decoders that can fail (hexDecode / urlDecode / base64Decode) ARE exposed
17
- // here with parity error semantics: a `0` write on non-empty input throws
18
- // (the napi decoders throw rather than return a short buffer). The only
19
- // unavoidable divergence is decompressing a stream that yields exactly 0
20
- // bytes, which the C ABI can't distinguish from "too small" (napi returns
21
- // an empty buffer; ffi throws) — degenerate and not exercised by tests.
16
+ // - A one-time SELF-TEST runs at bind time (ffi/selftest.ts); any failure
17
+ // disables ffi and falls back to the napi addon.
18
+ // - Decoders that can fail ARE exposed with parity error semantics: a `0`
19
+ // write on non-empty input throws (the napi decoders throw rather than
20
+ // return a short buffer).
22
21
  // - Never called under Node (falls back to napi immediately).
23
22
  //
24
23
  // Buffer ABI: input/out pointers are `(ptr, len)` pairs; the JS wrapper passes
25
24
  // `(view, view.length)` and bun:ffi converts the TypedArray to its pointer.
26
25
 
27
- // Type-only: erased at compile time (no runtime import of `bun:ffi` — that
28
- // stays lazy inside `bind()`). `FFITypeOrString` is the dlopen ABI-string union.
29
26
  import type { FFITypeOrString } from 'bun:ffi'
27
+ import { decodeUtf8, encodeUtf8 } from '../shared/codec'
30
28
  import { resolveEnvVar } from '../shared/env'
31
29
  import { isBun } from '../shared/runtime'
30
+ import {
31
+ COMPRESS_HEADROOM,
32
+ COMPRESS_INITIAL_CAP,
33
+ COMPRESS_MAX_CAP,
34
+ DECOMPRESS_FALLBACK_CAP,
35
+ DECOMPRESS_GUESS_MULTIPLIER_BROTLI,
36
+ DECOMPRESS_GUESS_MULTIPLIER_GZIP,
37
+ DECOMPRESS_MIN_INITIAL,
38
+ EMPTY_VIEW,
39
+ MAX_DECOMPRESSED,
40
+ MAX_JSON_PATCH_OUTPUT,
41
+ } from './ffi/constants'
42
+ import { selfTest } from './ffi/selftest'
43
+ import type {
44
+ BunFFI,
45
+ FfiMode,
46
+ Raw2,
47
+ Raw3,
48
+ Raw4,
49
+ Raw5,
50
+ Raw6,
51
+ Raw7,
52
+ Raw8,
53
+ Raw9,
54
+ Raw10,
55
+ Raw12,
56
+ RawCStr,
57
+ } from './ffi/types'
32
58
  // Static import: loader.ts has no side effects (path resolution only — no
33
59
  // dlopen), so this adds zero import-time cost while letting `bind()` reuse the
34
60
  // exact same resolved `.node` path the napi fallback uses (the shared seam).
35
61
  import { getAddonPath } from './loader'
36
62
 
37
- const encoder = new TextEncoder()
38
-
39
- /** The set of C-ABI functions this layer can accelerate on Bun. */
40
- export interface BunFFI {
41
- crc32(input: Uint8Array): number
42
- fnv1a64(input: Uint8Array): bigint
43
- xxh3(input: Uint8Array): bigint
44
- jsonValid(input: Uint8Array): boolean
45
- /** Lowercase-hex encode into a fresh `Uint8Array` (size `input.length * 2`). */
46
- hexEncode(input: Uint8Array): Uint8Array
47
- /** Lowercase-hex encode into `output`; returns bytes written. */
48
- hexEncodeInto(input: Uint8Array, output: Uint8Array): number
49
- /** RFC 3986 percent-encode into a fresh buffer (size `input.length * 3`). */
50
- urlEncode(input: Uint8Array): Uint8Array
51
- /** RFC 3986 percent-encode into `output`; returns bytes written. */
52
- urlEncodeInto(input: Uint8Array, output: Uint8Array): number
53
-
54
- // ── Validators (u8 → boolean) ─────────────────────────────────────
55
- validateEmail(input: Uint8Array): boolean
56
- validateUuid(input: Uint8Array): boolean
57
- validateIpv4(input: Uint8Array): boolean
58
- validateIpv6(input: Uint8Array): boolean
59
- /** Sum of `id` fields across a JSON array → bigint (throws on non-array input). */
60
- jsonSumIds(input: Uint8Array): bigint
61
-
62
- // ── Constant-time verify (u8 → boolean) ───────────────────────────
63
- hmacSha256Verify(key: Uint8Array, data: Uint8Array, signature: Uint8Array): boolean
64
- csrfVerify(token: Uint8Array, secret: Uint8Array): boolean
65
- passwordVerify(password: Uint8Array, phc: Uint8Array): boolean
66
- passwordVerifyBcrypt(password: Uint8Array, phc: Uint8Array): boolean
67
-
68
- // ── Decoders (throw on malformed input — napi `Result` parity) ──
69
- /** Hex-decode into a fresh buffer (size `input.length / 2`). */
70
- hexDecode(input: Uint8Array): Uint8Array
71
- /** Hex-decode into `output`; returns bytes written (throws if too small). */
72
- hexDecodeInto(input: Uint8Array, output: Uint8Array): number
73
- /** Percent-decode into a fresh buffer (size `input.length`). */
74
- urlDecode(input: Uint8Array): Uint8Array
75
- /** Percent-decode into `output`; returns bytes written (throws if too small). */
76
- urlDecodeInto(input: Uint8Array, output: Uint8Array): number
77
- /** base64-decode into a fresh buffer (size `ceil(len/4)*3`). */
78
- base64Decode(input: Uint8Array, urlSafe?: boolean, padding?: boolean): Uint8Array
79
- /** base64-decode into `output`; returns bytes written (throws if too small). */
80
- base64DecodeInto(
81
- input: Uint8Array,
82
- output: Uint8Array,
83
- urlSafe?: boolean,
84
- padding?: boolean,
85
- ): number
86
-
87
- // ── Fixed-size output writers ─────────────────────────────────────
88
- /** RFC 6455 Sec-WebSocket-Accept (28 bytes) into a fresh buffer. */
89
- wsAcceptKey(key: Uint8Array): Uint8Array
90
- /** crc32 ETag (10 strong / 12 weak bytes) into a fresh buffer. */
91
- etag(data: Uint8Array, weak?: boolean): Uint8Array
92
- /** crc32 ETag into `output`; returns bytes written. */
93
- etagInto(data: Uint8Array, output: Uint8Array, weak?: boolean): number
94
- /** `byteLen` random bytes → `byteLen * 2` hex chars. */
95
- randomToken(byteLen: number): Uint8Array
96
- /** base64-encode into a fresh buffer (size `ceil(len/3)*4`). */
97
- base64Encode(input: Uint8Array, urlSafe?: boolean, padding?: boolean): Uint8Array
98
- /** base64-encode into `output`; returns bytes written. */
99
- base64EncodeInto(
100
- input: Uint8Array,
101
- output: Uint8Array,
102
- urlSafe?: boolean,
103
- padding?: boolean,
104
- ): number
105
- /** HMAC-SHA256 hex (64 chars) into a fresh buffer. */
106
- hmacSha256(key: Uint8Array, data: Uint8Array): Uint8Array
107
- /** HMAC-SHA256 hex into `output`; returns bytes written (throws if too small). */
108
- hmacSha256Into(key: Uint8Array, data: Uint8Array, output: Uint8Array): number
109
- /** Sign cookie `value` as `value.<64-hex>` into a fresh buffer. */
110
- signCookie(value: Uint8Array, secret: Uint8Array): Uint8Array
111
- /** Sign cookie into `output`; returns bytes written (throws if too small). */
112
- signCookieInto(value: Uint8Array, secret: Uint8Array, output: Uint8Array): number
113
- /** Verify a signed cookie → value bytes, or `null` on bad signature. */
114
- verifyCookie(signed: Uint8Array, secret: Uint8Array): Uint8Array | null
115
- /** CSRF token (129 bytes: 64 rnd-hex + '.' + 64 sig-hex). */
116
- csrfToken(secret: Uint8Array): Uint8Array
117
- /** Argon2id PHC hash bytes (m/t/p/out_len params). */
118
- passwordHash(
119
- password: Uint8Array,
120
- salt: Uint8Array,
121
- mCost: number,
122
- tCost: number,
123
- pCost: number,
124
- outLen: number,
125
- ): Uint8Array
126
- /** bcrypt `$2b$` PHC string bytes (cost clamped 4..=31). */
127
- passwordHashBcrypt(password: Uint8Array, cost: number): Uint8Array
128
- /** PBKDF2-HMAC-SHA256 → `dkLen` bytes (rounds, dkLen clamped). */
129
- pbkdf2Sha256(password: Uint8Array, salt: Uint8Array, rounds: number, dkLen: number): Uint8Array
130
- /** AEAD encrypt (0 = AES-256-GCM, 1 = ChaCha20-Poly1305) → ct+tag. */
131
- aeadEncrypt(
132
- key: Uint8Array,
133
- nonce: Uint8Array,
134
- plaintext: Uint8Array,
135
- algorithm?: number,
136
- ): Uint8Array
137
- /** AEAD encrypt into `output` (ct + 16-byte tag); returns bytes written. */
138
- aeadEncryptInto(
139
- key: Uint8Array,
140
- nonce: Uint8Array,
141
- plaintext: Uint8Array,
142
- output: Uint8Array,
143
- algorithm?: number,
144
- ): number
145
- /** AEAD decrypt → plaintext, or `null` on auth failure. */
146
- aeadDecrypt(
147
- key: Uint8Array,
148
- nonce: Uint8Array,
149
- ciphertext: Uint8Array,
150
- algorithm?: number,
151
- ): Uint8Array | null
152
-
153
- // ── Frame / patch / compression (variable-size → grow-retry) ─────
154
- /** RFC 6455 frame encode into a fresh buffer. */
155
- wsFrameEncode(opcode: number, payload: Uint8Array, mask: boolean, fin: boolean): Uint8Array
156
- /** RFC 6455 frame encode into `output`; returns bytes written (throws if too small). */
157
- wsFrameEncodeInto(
158
- opcode: number,
159
- payload: Uint8Array,
160
- mask: boolean,
161
- fin: boolean,
162
- output: Uint8Array,
163
- ): number
164
- /** RFC 6902 JSON patch into a fresh buffer. */
165
- jsonPatch(doc: Uint8Array, patch: Uint8Array): Uint8Array
166
- /** gzip-compress into a fresh buffer (level clamped 0..=9, default 6). */
167
- gzipCompress(data: Uint8Array, level?: number): Uint8Array
168
- /** gzip-compress into `output`; returns bytes written (throws if too small). */
169
- gzipCompressInto(data: Uint8Array, output: Uint8Array, level?: number): number
170
- /** gzip-decompress into a fresh buffer (capped by `maxDecompressed`). */
171
- gzipDecompress(data: Uint8Array, maxDecompressed?: number): Uint8Array
172
- /** brotli-compress into a fresh buffer (quality clamped 0..=11, default 5). */
173
- brotliCompress(data: Uint8Array, quality?: number): Uint8Array
174
- /** brotli-compress into `output`; returns bytes written (throws if too small). */
175
- brotliCompressInto(data: Uint8Array, output: Uint8Array, quality?: number): number
176
- /** brotli-decompress into a fresh buffer (capped by `maxDecompressed`). */
177
- brotliDecompress(data: Uint8Array, maxDecompressed?: number): Uint8Array
178
-
179
- // ── Packed parsers (into caller buffers) ─────────────────────────
180
- /** HTTP request parse → packed output into `output`; returns bytes written. */
181
- httpParseRequestPackedInto(input: Uint8Array, output: Uint8Array): number
182
- /** Query string parse → packed output into `output`; returns bytes written. */
183
- queryParsePackedInto(input: Uint8Array, output: Uint8Array): number
184
- /** Cookie header parse → packed output into `output`; returns bytes written. */
185
- cookieParsePackedInto(input: Uint8Array, output: Uint8Array): number
186
-
187
- // ── Excluded-surface additions (packed / opaque-handle) ─────────
188
- /** Sign JWT (HS256) from pre-serialized claim JSON (ttl<=0 → no iat/exp). */
189
- jwtSignBytes(claimsJson: Uint8Array, secret: Uint8Array, ttl: number, now: number): Uint8Array
190
- /** Decode a WS frame into packed `[flags][opcode][u32 len][payload]`; null on malformed. */
191
- wsFrameDecodePacked(data: Uint8Array): Uint8Array | null
192
- /** Parse multipart/form-data into the packed parts layout. */
193
- multipartParsePacked(body: Uint8Array, boundary: Uint8Array): Uint8Array
194
- /** Parse x-www-form-urlencoded into packed pairs into `output`. */
195
- formParsePackedInto(input: Uint8Array, output: Uint8Array): number
196
- /**
197
- * Run the ingress pipeline on a packed frame via the opaque inner handle from
198
- * `Ingress.ingressInnerPtr()` (valid only while the instance is alive — the
199
- * caller must hold it). Returns bytes written; throws on error / too-small.
200
- */
201
- ingressHandlePacked(
202
- inner: number,
203
- input: Uint8Array,
204
- body: Uint8Array | null,
205
- output: Uint8Array,
206
- ): number
207
- /**
208
- * Write the ingress binary-layout constants (38 × u32 LE — `rust/ffi.rs`
209
- * `IngressLayout`, numeric source `rust/ingress/output.rs`) into `output`;
210
- * returns bytes written. Lets `src/ingress/constants.ts` read the layout via
211
- * bun:ffi on Bun so importing the package does NOT dlopen the napi addon.
212
- */
213
- ingressLayout(out: Uint8Array): number
214
- }
215
-
216
- // ── Caps mirrored from the Rust napi layer ────────────────────────
217
- // rust/payload/compress.rs DEFAULT_MAX_DECOMPRESSED (64 MiB decompression bomb
218
- // guard — must stay in sync; do not raise without benchmarking).
219
- const MAX_DECOMPRESSED = 64 * 1024 * 1024
220
- // rust/json/json_patch_ops.rs MAX_JSON_PATCH_OUTPUT (128 MiB).
221
- const MAX_JSON_PATCH_OUTPUT = 128 * 1024 * 1024
222
-
223
- // ── Output-buffer sizing heuristics (measured) ─────────────────────
224
- // These are ALLOCATION caps only, not correctness bounds: `growExact` covers
225
- // any residual miss with at most one exact-size retry (no re-run loop), so a
226
- // guess that is too small costs one extra native pass, never a wrong result.
227
- //
228
- // Compress: a 75 KiB input compresses to <1 KiB, so a 16 KiB initial is
229
- // plenty and single-pass; the 1 MiB ceiling bounds incompressible data.
230
- const COMPRESS_INITIAL_CAP = 16 * 1024
231
- const COMPRESS_MAX_CAP = 1024 * 1024
232
- const COMPRESS_HEADROOM = 64 // small-input slack above `data.length`
233
- // Decompress (no trailer / expensive trailer): typical JSON/text ratios.
234
- const DECOMPRESS_GUESS_MULTIPLIER_GZIP = 16
235
- const DECOMPRESS_GUESS_MULTIPLIER_BROTLI = 32
236
- const DECOMPRESS_FALLBACK_CAP = 4 * 1024 * 1024 // 4 MiB over-alloc bound
237
- const DECOMPRESS_MIN_INITIAL = 1024
238
- // JWT: token ≈ header(~36) + payload(≈4/3× claims) + sig(~43) + 2 dots. The
239
- // old `claims.length + 128` under-sized a typical token (measured 168 B for a
240
- // 30 B claim → grow-retry double-run, making ffi slower than napi). 2×+128
241
- // with a 256-byte floor covers typical claims in one pass.
242
- const JWT_INITIAL_MULTIPLIER = 2
243
- const JWT_INITIAL_EXTRA = 128
244
- const JWT_INITIAL_FLOOR = 256
245
-
246
- // ── Known-good vectors for the bind-time self-test ───────────────
247
- // These mirror the Rust `#[cfg(test)]` vectors in rust/ffi.rs.
248
- const SELFTEST_HEX = encoder.encode('hello') // -> 68656c6c6f
249
- const SELFTEST_JSON = encoder.encode('{"a":1}')
250
-
251
- // Empty view for `null` body slots in (ptr, len) pairs — the C side treats
252
- // len 0 as "no body" regardless of the pointer value.
253
- const EMPTY_VIEW = new Uint8Array(0)
63
+ // Re-export the type surface so `import type { BunFFI } from '../native/ffi'`
64
+ // keeps working (existing call sites).
65
+ export type { BunFFI, FfiMode } from './ffi/types'
254
66
 
255
67
  let cached: BunFFI | null | undefined
68
+ /**
69
+ * Resolved buffer ABI mode, set once by `bind()`. `null` = ffi unavailable
70
+ * (Node / `CASTRUM_FFI_MODE=napi` / failed self-test), `'buffer-pair'` = the
71
+ * engine-native `buffer`/`buffer_length` pair is live, `'ptr-len'` = the
72
+ * explicit `(ptr, usize)` fallback.
73
+ */
74
+ let bufferAbiMode: 'buffer-pair' | 'ptr-len' | null = null
256
75
 
257
76
  // ── Transport selection (CASTRUM_FFI_MODE) ───────────────────────
258
- // auto (default): use bun:ffi on Bun, silently fall back to napi when the
259
- // bind or self-test fails (and always on Node).
260
- // ffi : force bun:ffi on Bun — throw a clear error if it can't bind or the
261
- // self-test fails (use in benches/CI that MUST run ffi).
262
- // napi : never bind — every call goes through the napi addon (the fallback;
263
- // useful for exercising the fallback path on Bun).
264
- // The legacy alias `RUST_FFI_MODE` is accepted too (see src/shared/env.ts).
265
- export type FfiMode = 'auto' | 'ffi' | 'napi'
266
77
 
267
78
  function resolveFfiMode(): FfiMode {
268
- const raw = resolveEnvVar('CASTRUM_FFI_MODE', ['RUST_FFI_MODE'])
79
+ const raw = resolveEnvVar('CASTRUM_FFI_MODE')
269
80
  switch (raw) {
270
81
  case 'ffi':
271
82
  return 'ffi'
@@ -292,6 +103,23 @@ export function getBunFFI(): BunFFI | null {
292
103
  return cached
293
104
  }
294
105
 
106
+ /**
107
+ * Which buffer ABI the live `bun:ffi` binding uses, or `null` when the
108
+ * transport is unavailable (Node, `CASTRUM_FFI_MODE=napi`, or a failed
109
+ * self-test). `'buffer-pair'` = the engine-native `buffer`/`buffer_length`
110
+ * pair (atomic ptr+byteLength snapshot) is in use; `'ptr-len'` = the explicit
111
+ * `(ptr, usize)` fallback (older Bun / probe failure). Lazy — triggers the
112
+ * same one-time bind as {@link getBunFFI}. Under `CASTRUM_FFI_MODE=ffi` on Bun
113
+ * this is guaranteed non-null; a bind/self-test failure THROWS, matching
114
+ * `getBunFFI`.
115
+ */
116
+ export function ffiBufferMode(): 'buffer-pair' | 'ptr-len' | null {
117
+ if (cached === undefined) {
118
+ getBunFFI()
119
+ }
120
+ return bufferAbiMode
121
+ }
122
+
295
123
  /**
296
124
  * Probe whether this Bun build accepts the `buffer`/`buffer_length` ABI pair in
297
125
  * `dlopen`. Bun's docs list `buffer_length` as engine-native (dlopen-supported),
@@ -324,6 +152,7 @@ function probeBufferLength(dlopen: typeof import('bun:ffi')['dlopen'], path: str
324
152
  }
325
153
 
326
154
  function bind(): BunFFI | null {
155
+ bufferAbiMode = null // reset — the mode below is only valid for a live bind
327
156
  const mode = resolveFfiMode()
328
157
  if (!isBun()) {
329
158
  if (mode === 'ffi') {
@@ -349,6 +178,7 @@ function bind(): BunFFI | null {
349
178
  // TypedArray at call time — an atomic snapshot) once; when supported we use
350
179
  // it for the input-only hot fns (one JS arg instead of two), else `(ptr,len)`.
351
180
  const useBufferLength = probeBufferLength(dlopen, path)
181
+ bufferAbiMode = useBufferLength ? 'buffer-pair' : 'ptr-len'
352
182
 
353
183
  // String ABI specs ("ptr"/"usize"/"u32"...): accepted by bun:ffi and typed
354
184
  // via `FFITypeOrString` in bun-types. usize == uint64 on the host ABI.
@@ -359,115 +189,280 @@ function bind(): BunFFI | null {
359
189
  const inputAbi: readonly FFITypeOrString[] = useBufferLength
360
190
  ? (['buffer', 'buffer_length'] as unknown as readonly FFITypeOrString[])
361
191
  : ['ptr', 'usize']
192
+ // Phase 1.1: extend `buffer`/`buffer_length` to EVERY `(ptr,len)` pair, not
193
+ // just the input-only fns. The engine reads ptr + byteLength off the SAME
194
+ // view at call time (atomic snapshot), so call sites pass the view twice
195
+ // (see `lenOrView` below). Conversion is POSITIONAL and pair-aware: each
196
+ // `ptr` consumes its following `usize` as a length. Scalar args that happen
197
+ // to be `usize` (the opaque `inner` handle of `castrum_ingress_handle_packed`
198
+ // and the `max` param of gzip/brotli decompress) are passed through
199
+ // unchanged — a blind `usize`→`buffer_length` map would corrupt them.
200
+ const abi = (shape: readonly string[]): readonly FFITypeOrString[] => {
201
+ if (!useBufferLength) return shape as readonly FFITypeOrString[]
202
+ const out: FFITypeOrString[] = []
203
+ for (let i = 0; i < shape.length; i++) {
204
+ const t = shape[i]
205
+ if (t === 'ptr') {
206
+ out.push('buffer' as unknown as FFITypeOrString)
207
+ out.push('buffer_length' as unknown as FFITypeOrString)
208
+ i++ // consume the paired `usize`
209
+ } else {
210
+ out.push(t as FFITypeOrString)
211
+ }
212
+ }
213
+ return out
214
+ }
215
+ // `u64_fast` returns a plain `number` when the value fits (< 2^53) instead
216
+ // of a BigInt — bun-types lacks the literal, so bind it via a typed const
217
+ // (same cast pattern as the `buffer_length` probe above). Every bytes-written
218
+ // return fits in a double, so this removes the per-call BigInt boxing on the
219
+ // hot scalar / `*_into` / packed-parser / ingress calls.
220
+ const U64_FAST = 'u64_fast' as unknown as FFITypeOrString
362
221
  const { symbols } = dlopen(path, {
363
222
  castrum_crc32: { args: inputAbi, returns: 'u32' },
364
223
  castrum_fnv1a64: { args: inputAbi, returns: 'u64' },
365
224
  castrum_xxh3: { args: inputAbi, returns: 'u64' },
366
225
  castrum_json_valid: { args: inputAbi, returns: 'u8' },
367
- castrum_hex_encode: { args: ['ptr', 'usize', 'ptr', 'usize'], returns: 'usize' },
368
- castrum_hex_decode: { args: ['ptr', 'usize', 'ptr', 'usize'], returns: 'usize' },
369
- castrum_url_encode: { args: ['ptr', 'usize', 'ptr', 'usize'], returns: 'usize' },
370
- castrum_url_decode: { args: ['ptr', 'usize', 'ptr', 'usize'], returns: 'usize' },
371
- castrum_validate_email: { args: inputAbi, returns: 'u8' },
372
- castrum_validate_uuid: { args: inputAbi, returns: 'u8' },
373
- castrum_validate_ipv4: { args: inputAbi, returns: 'u8' },
374
- castrum_validate_ipv6: { args: inputAbi, returns: 'u8' },
375
- castrum_json_sum_ids: { args: ['ptr', 'usize', 'ptr', 'usize'], returns: 'usize' },
226
+ castrum_utf8_valid: { args: inputAbi, returns: 'u8' },
227
+ castrum_hex_encode: { args: abi(['ptr', 'usize', 'ptr', 'usize']), returns: U64_FAST },
228
+ castrum_hex_decode: { args: abi(['ptr', 'usize', 'ptr', 'usize']), returns: U64_FAST },
229
+ castrum_url_encode: { args: abi(['ptr', 'usize', 'ptr', 'usize']), returns: U64_FAST },
230
+ castrum_url_decode: { args: abi(['ptr', 'usize', 'ptr', 'usize']), returns: U64_FAST },
231
+ // String-input validators: `cstring` ARG (the engine transcodes the JS
232
+ // string in-engine — no JS-side encode; Rust borrows via CStr::from_ptr).
233
+ castrum_validate_email: { args: ['cstring'], returns: 'u8' },
234
+ castrum_validate_uuid: { args: ['cstring'], returns: 'u8' },
235
+ castrum_validate_ipv4: { args: ['cstring'], returns: 'u8' },
236
+ castrum_validate_ipv6: { args: ['cstring'], returns: 'u8' },
237
+ castrum_json_sum_ids: { args: abi(['ptr', 'usize', 'ptr', 'usize']), returns: U64_FAST },
376
238
  castrum_hmac_sha256_verify: {
377
- args: ['ptr', 'usize', 'ptr', 'usize', 'ptr', 'usize'],
239
+ args: abi(['ptr', 'usize', 'ptr', 'usize', 'ptr', 'usize']),
240
+ returns: 'u8',
241
+ },
242
+ castrum_csrf_verify: { args: abi(['ptr', 'usize', 'ptr', 'usize']), returns: 'u8' },
243
+ castrum_password_verify: { args: abi(['ptr', 'usize', 'ptr', 'usize']), returns: 'u8' },
244
+ castrum_password_verify_bcrypt: {
245
+ // `phc` crosses as a `cstring` ARG; `password` stays a `(ptr,len)` pair.
246
+ args: abi(['ptr', 'usize', 'cstring']),
247
+ returns: 'u8',
248
+ },
249
+ castrum_ws_accept_key: { args: ['cstring'], returns: 'cstring' },
250
+ castrum_ws_accept_key_into: {
251
+ args: abi(['ptr', 'usize', 'ptr', 'usize']),
252
+ returns: U64_FAST,
253
+ },
254
+ castrum_etag: { args: abi(['ptr', 'usize', 'u8']), returns: 'cstring' },
255
+ castrum_etag_into: { args: abi(['ptr', 'usize', 'u8', 'ptr', 'usize']), returns: U64_FAST },
256
+ castrum_conditional_is_not_modified: {
257
+ args: abi(['usize', 'ptr', 'usize', 'ptr', 'usize', 'u8']),
258
+ returns: 'u8',
259
+ },
260
+ castrum_media_type_matcher_matches: {
261
+ args: abi(['usize', 'ptr', 'usize']),
378
262
  returns: 'u8',
379
263
  },
380
- castrum_csrf_verify: { args: ['ptr', 'usize', 'ptr', 'usize'], returns: 'u8' },
381
- castrum_password_verify: { args: ['ptr', 'usize', 'ptr', 'usize'], returns: 'u8' },
382
- castrum_password_verify_bcrypt: { args: ['ptr', 'usize', 'ptr', 'usize'], returns: 'u8' },
383
- castrum_ws_accept_key: { args: ['ptr', 'usize', 'ptr', 'usize'], returns: 'usize' },
384
- castrum_etag: { args: ['ptr', 'usize', 'ptr', 'usize', 'u8'], returns: 'usize' },
385
- castrum_random_token: { args: ['u32', 'ptr', 'usize'], returns: 'usize' },
264
+ castrum_accept_negotiator_negotiate: {
265
+ args: abi(['usize', 'ptr', 'usize']),
266
+ returns: 'cstring',
267
+ },
268
+ castrum_jwt_signer_sign: {
269
+ args: abi(['usize', 'ptr', 'usize', 'i64', 'ptr', 'usize']),
270
+ returns: U64_FAST,
271
+ },
272
+ castrum_jwt_signer_verify: {
273
+ args: abi(['usize', 'ptr', 'usize', 'i64', 'ptr', 'usize']),
274
+ returns: U64_FAST,
275
+ },
276
+ castrum_template_render: {
277
+ args: abi(['usize', 'ptr', 'usize', 'ptr', 'usize']),
278
+ returns: U64_FAST,
279
+ },
280
+ castrum_schema_validator_validate: {
281
+ args: abi(['usize', 'ptr', 'usize']),
282
+ returns: 'u8',
283
+ },
284
+ castrum_rate_limiter_check: {
285
+ // `key` crosses as a `cstring` ARG (text, no NUL in the intended use).
286
+ args: abi(['usize', 'cstring', 'i64', 'ptr', 'usize']),
287
+ returns: U64_FAST,
288
+ },
289
+ castrum_rate_limiter_check_key: {
290
+ args: abi(['usize', 'i64', 'i64', 'ptr', 'usize']),
291
+ returns: U64_FAST,
292
+ },
293
+ castrum_random_token: { args: ['u32'], returns: 'cstring' },
294
+ castrum_random_token_into: { args: ['u32', ...abi(['ptr', 'usize'])], returns: U64_FAST },
386
295
  castrum_base64_encode: {
387
- args: ['ptr', 'usize', 'ptr', 'usize', 'u8', 'u8'],
388
- returns: 'usize',
296
+ args: abi(['ptr', 'usize', 'ptr', 'usize', 'u8', 'u8']),
297
+ returns: U64_FAST,
389
298
  },
390
299
  castrum_base64_decode: {
391
- args: ['ptr', 'usize', 'ptr', 'usize', 'u8', 'u8'],
392
- returns: 'usize',
300
+ args: abi(['ptr', 'usize', 'ptr', 'usize', 'u8', 'u8']),
301
+ returns: U64_FAST,
393
302
  },
394
303
  castrum_hmac_sha256: {
395
- args: ['ptr', 'usize', 'ptr', 'usize', 'ptr', 'usize'],
396
- returns: 'usize',
304
+ args: abi(['ptr', 'usize', 'ptr', 'usize', 'ptr', 'usize']),
305
+ returns: U64_FAST,
397
306
  },
398
- castrum_sign_cookie: {
399
- args: ['ptr', 'usize', 'ptr', 'usize', 'ptr', 'usize'],
400
- returns: 'usize',
307
+ castrum_sign_cookie: { args: abi(['ptr', 'usize', 'ptr', 'usize']), returns: 'cstring' },
308
+ castrum_sign_cookie_into: {
309
+ args: abi(['ptr', 'usize', 'ptr', 'usize', 'ptr', 'usize']),
310
+ returns: U64_FAST,
401
311
  },
402
- castrum_verify_cookie: {
403
- args: ['ptr', 'usize', 'ptr', 'usize', 'ptr', 'usize'],
404
- returns: 'usize',
312
+ castrum_verify_cookie: { args: abi(['ptr', 'usize', 'ptr', 'usize']), returns: 'cstring' },
313
+ castrum_verify_cookie_into: {
314
+ args: abi(['ptr', 'usize', 'ptr', 'usize', 'ptr', 'usize']),
315
+ returns: U64_FAST,
405
316
  },
406
- castrum_csrf_token: { args: ['ptr', 'usize', 'ptr', 'usize'], returns: 'usize' },
317
+ castrum_csrf_token: { args: abi(['ptr', 'usize']), returns: 'cstring' },
318
+ castrum_csrf_token_into: { args: abi(['ptr', 'usize', 'ptr', 'usize']), returns: U64_FAST },
407
319
  castrum_password_hash: {
408
- args: ['ptr', 'usize', 'ptr', 'usize', 'u32', 'u32', 'u32', 'u32', 'ptr', 'usize'],
409
- returns: 'usize',
320
+ args: abi(['ptr', 'usize', 'ptr', 'usize', 'u32', 'u32', 'u32', 'u32', 'ptr', 'usize']),
321
+ returns: U64_FAST,
410
322
  },
411
323
  castrum_password_hash_bcrypt: {
412
- args: ['ptr', 'usize', 'u32', 'ptr', 'usize'],
413
- returns: 'usize',
324
+ args: abi(['ptr', 'usize', 'u32', 'ptr', 'usize']),
325
+ returns: U64_FAST,
414
326
  },
415
327
  castrum_pbkdf2_sha256: {
416
- args: ['ptr', 'usize', 'ptr', 'usize', 'u32', 'u32', 'ptr', 'usize'],
417
- returns: 'usize',
328
+ args: abi(['ptr', 'usize', 'ptr', 'usize', 'u32', 'u32', 'ptr', 'usize']),
329
+ returns: U64_FAST,
418
330
  },
419
331
  castrum_aead_encrypt: {
420
- args: ['ptr', 'usize', 'ptr', 'usize', 'ptr', 'usize', 'u8', 'ptr', 'usize'],
421
- returns: 'usize',
332
+ args: abi(['ptr', 'usize', 'ptr', 'usize', 'ptr', 'usize', 'u8', 'ptr', 'usize']),
333
+ returns: U64_FAST,
422
334
  },
423
335
  castrum_aead_decrypt: {
424
- args: ['ptr', 'usize', 'ptr', 'usize', 'ptr', 'usize', 'u8', 'ptr', 'usize'],
425
- returns: 'usize',
336
+ args: abi(['ptr', 'usize', 'ptr', 'usize', 'ptr', 'usize', 'u8', 'ptr', 'usize']),
337
+ returns: U64_FAST,
426
338
  },
427
339
  castrum_ws_frame_encode: {
428
- args: ['u8', 'ptr', 'usize', 'u8', 'u8', 'ptr', 'usize'],
429
- returns: 'usize',
340
+ args: abi(['u8', 'ptr', 'usize', 'u8', 'u8', 'ptr', 'usize']),
341
+ returns: U64_FAST,
430
342
  },
431
343
  castrum_json_patch: {
432
- args: ['ptr', 'usize', 'ptr', 'usize', 'ptr', 'usize'],
433
- returns: 'usize',
344
+ args: abi(['ptr', 'usize', 'ptr', 'usize', 'ptr', 'usize']),
345
+ returns: U64_FAST,
346
+ },
347
+ castrum_gzip_compress: {
348
+ args: abi(['ptr', 'usize', 'u32', 'ptr', 'usize']),
349
+ returns: U64_FAST,
434
350
  },
435
- castrum_gzip_compress: { args: ['ptr', 'usize', 'u32', 'ptr', 'usize'], returns: 'usize' },
436
351
  castrum_gzip_decompress: {
437
- args: ['ptr', 'usize', 'usize', 'ptr', 'usize'],
438
- returns: 'usize',
352
+ args: abi(['ptr', 'usize', 'usize', 'ptr', 'usize']),
353
+ returns: U64_FAST,
354
+ },
355
+ castrum_brotli_compress: {
356
+ args: abi(['ptr', 'usize', 'u32', 'ptr', 'usize']),
357
+ returns: U64_FAST,
439
358
  },
440
- castrum_brotli_compress: { args: ['ptr', 'usize', 'u32', 'ptr', 'usize'], returns: 'usize' },
441
359
  castrum_brotli_decompress: {
442
- args: ['ptr', 'usize', 'usize', 'ptr', 'usize'],
443
- returns: 'usize',
360
+ args: abi(['ptr', 'usize', 'usize', 'ptr', 'usize']),
361
+ returns: U64_FAST,
444
362
  },
445
- castrum_gzip_isize: { args: ['ptr', 'usize'], returns: 'u32' },
363
+ castrum_gzip_isize: { args: abi(['ptr', 'usize']), returns: 'u32' },
446
364
  castrum_http_parse_request_packed: {
447
- args: ['ptr', 'usize', 'ptr', 'usize'],
448
- returns: 'usize',
365
+ args: abi(['ptr', 'usize', 'ptr', 'usize']),
366
+ returns: U64_FAST,
367
+ },
368
+ castrum_query_parse_packed: {
369
+ args: abi(['ptr', 'usize', 'ptr', 'usize']),
370
+ returns: U64_FAST,
371
+ },
372
+ castrum_cookie_parse_packed: {
373
+ args: abi(['ptr', 'usize', 'ptr', 'usize']),
374
+ returns: U64_FAST,
375
+ },
376
+ castrum_http_date_into: { args: ['f64', ...abi(['ptr', 'usize'])], returns: U64_FAST },
377
+ castrum_sse_encode_into: {
378
+ args: abi(['ptr', 'usize', 'ptr', 'usize', 'ptr', 'usize', 'u8', 'u32', 'ptr', 'usize']),
379
+ returns: U64_FAST,
449
380
  },
450
- castrum_query_parse_packed: { args: ['ptr', 'usize', 'ptr', 'usize'], returns: 'usize' },
451
- castrum_cookie_parse_packed: { args: ['ptr', 'usize', 'ptr', 'usize'], returns: 'usize' },
452
381
  // ── Excluded-surface additions ──
453
382
  castrum_jwt_sign_bytes: {
454
- args: ['ptr', 'usize', 'ptr', 'usize', 'i64', 'i64', 'ptr', 'usize'],
455
- returns: 'usize',
383
+ args: abi(['ptr', 'usize', 'ptr', 'usize', 'i64', 'i64']),
384
+ returns: 'cstring',
385
+ },
386
+ castrum_jwt_sign_bytes_into: {
387
+ args: abi(['ptr', 'usize', 'ptr', 'usize', 'i64', 'i64', 'ptr', 'usize']),
388
+ returns: U64_FAST,
456
389
  },
457
390
  castrum_ws_frame_decode_packed: {
458
- args: ['ptr', 'usize', 'ptr', 'usize'],
459
- returns: 'usize',
391
+ args: abi(['ptr', 'usize', 'ptr', 'usize']),
392
+ returns: U64_FAST,
460
393
  },
461
394
  castrum_multipart_parse_packed: {
462
- args: ['ptr', 'usize', 'ptr', 'usize', 'ptr', 'usize'],
463
- returns: 'usize',
395
+ args: abi(['ptr', 'usize', 'ptr', 'usize', 'ptr', 'usize']),
396
+ returns: U64_FAST,
397
+ },
398
+ castrum_form_parse_packed: { args: abi(['ptr', 'usize', 'ptr', 'usize']), returns: U64_FAST },
399
+ // ── Phase 3 — stateless napi-only scalars (now FFI) ──────────
400
+ // Structural parse: parses ONCE, emits a packed token stream with a
401
+ // deduped string table so JS assembles the value with NO second parse.
402
+ castrum_json_parse_packed: {
403
+ args: abi(['ptr', 'usize', 'ptr', 'usize']),
404
+ returns: U64_FAST,
405
+ },
406
+ castrum_parse_media_type: {
407
+ args: abi(['ptr', 'usize', 'ptr', 'usize']),
408
+ returns: U64_FAST,
409
+ },
410
+ castrum_parse_http_date: {
411
+ args: abi(['ptr', 'usize', 'ptr', 'usize']),
412
+ returns: U64_FAST,
413
+ },
414
+ castrum_parse_accept_encoding: {
415
+ args: abi(['ptr', 'usize', 'ptr', 'usize']),
416
+ returns: U64_FAST,
417
+ },
418
+ castrum_url_encode_query: { args: abi(['ptr', 'usize']), returns: 'cstring' },
419
+ castrum_url_resolve: {
420
+ args: abi(['ptr', 'usize', 'ptr', 'usize']),
421
+ returns: 'cstring',
422
+ },
423
+ castrum_url_builder_resolve: {
424
+ args: abi(['usize', 'ptr', 'usize', 'ptr', 'usize']),
425
+ returns: U64_FAST,
426
+ },
427
+ castrum_mime_from_extension: { args: ['cstring'], returns: 'cstring' },
428
+ castrum_jwt_verify: {
429
+ args: abi(['ptr', 'usize', 'ptr', 'usize', 'i64']),
430
+ returns: 'cstring',
464
431
  },
465
- castrum_form_parse_packed: { args: ['ptr', 'usize', 'ptr', 'usize'], returns: 'usize' },
466
432
  castrum_ingress_handle_packed: {
467
- args: ['usize', 'ptr', 'usize', 'ptr', 'usize', 'ptr', 'usize'],
468
- returns: 'usize',
433
+ args: abi(['usize', 'ptr', 'usize', 'ptr', 'usize', 'ptr', 'usize']),
434
+ returns: U64_FAST,
435
+ },
436
+ // Raw-components sibling of `castrum_ingress_handle_packed`: `url`/`ip`
437
+ // are `cstring` ARGs (Bun transcodes the JS strings to call-scoped
438
+ // NUL-terminated buffers in-engine — no JS-side Buffer.write encode);
439
+ // rid/headers/body are `(ptr,len)` byte slices; out is `(ptr,len)`.
440
+ castrum_ingress_handle_components: {
441
+ args: abi([
442
+ 'usize',
443
+ 'u8',
444
+ 'cstring',
445
+ 'cstring',
446
+ 'ptr',
447
+ 'usize',
448
+ 'ptr',
449
+ 'usize',
450
+ 'ptr',
451
+ 'usize',
452
+ 'ptr',
453
+ 'usize',
454
+ ]),
455
+ returns: U64_FAST,
469
456
  },
470
- castrum_ingress_layout: { args: ['ptr', 'usize'], returns: 'usize' },
457
+ castrum_ingress_layout: { args: abi(['ptr', 'usize']), returns: U64_FAST },
458
+ // Per-route native stack (`@ignex/native` also dlopens these exact names
459
+ // itself; castrum binds them for the bind-time self-test + parity guard).
460
+ castrum_route_compile: { args: abi(['ptr', 'usize']), returns: U64_FAST },
461
+ castrum_route_run: {
462
+ args: abi(['usize', 'ptr', 'usize', 'ptr', 'usize']),
463
+ returns: U64_FAST,
464
+ },
465
+ castrum_route_destroy: { args: ['usize'], returns: 'void' },
471
466
  })
472
467
 
473
468
  const bindings = build(symbols as Record<string, (...a: unknown[]) => unknown>, useBufferLength)
@@ -493,60 +488,6 @@ function bind(): BunFFI | null {
493
488
 
494
489
  // Types for the raw dlopen'd functions (bun:ffi converts Uint8Array -> ptr for
495
490
  // `ptr` args; `usize`/`u64`/`i64` returns surface as BigInt).
496
- type Raw2 = (a: unknown, b: unknown) => number | bigint
497
- type Raw3 = (a: unknown, b: unknown, c: unknown) => number | bigint
498
- type Raw4 = (a: unknown, b: unknown, c: unknown, d: unknown) => number | bigint
499
- type Raw5 = (a: unknown, b: unknown, c: unknown, d: unknown, e: unknown) => number | bigint
500
- type Raw6 = (
501
- a: unknown,
502
- b: unknown,
503
- c: unknown,
504
- d: unknown,
505
- e: unknown,
506
- f: unknown,
507
- ) => number | bigint
508
- type Raw7 = (
509
- a: unknown,
510
- b: unknown,
511
- c: unknown,
512
- d: unknown,
513
- e: unknown,
514
- f: unknown,
515
- g: unknown,
516
- ) => number | bigint
517
- type Raw8 = (
518
- a: unknown,
519
- b: unknown,
520
- c: unknown,
521
- d: unknown,
522
- e: unknown,
523
- f: unknown,
524
- g: unknown,
525
- h: unknown,
526
- ) => number | bigint
527
- type Raw9 = (
528
- a: unknown,
529
- b: unknown,
530
- c: unknown,
531
- d: unknown,
532
- e: unknown,
533
- f: unknown,
534
- g: unknown,
535
- h: unknown,
536
- i: unknown,
537
- ) => number | bigint
538
- type Raw10 = (
539
- a: unknown,
540
- b: unknown,
541
- c: unknown,
542
- d: unknown,
543
- e: unknown,
544
- f: unknown,
545
- g: unknown,
546
- h: unknown,
547
- i: unknown,
548
- j: unknown,
549
- ) => number | bigint
550
491
 
551
492
  const flag = (v?: boolean): number => (v ? 1 : 0)
552
493
 
@@ -584,17 +525,37 @@ function writeOrThrow(w: number, inputLen: number, label: string): number {
584
525
  return w
585
526
  }
586
527
 
528
+ /** Unpack the packed rate-limit verdict `[u8 allowed][u32 remaining LE][i64 reset_ms LE]`. */
529
+ function unpackRateCheck(
530
+ out: Uint8Array,
531
+ view: DataView,
532
+ ): {
533
+ allowed: boolean
534
+ remaining: number
535
+ resetMs: number
536
+ } {
537
+ return {
538
+ allowed: out[0] === 1,
539
+ remaining: view.getUint32(1, true),
540
+ resetMs: Number(view.getBigInt64(5, true)),
541
+ }
542
+ }
543
+
587
544
  /** base64 (no-pad) length of `n` bytes. */
588
545
  const b64Len = (n: number): number => (n === 0 ? 0 : Math.ceil(n / 3) * 4 - ((3 - (n % 3)) % 3))
589
546
 
590
547
  /**
591
- * Exact length of an argon2id PHC string for the given params (m/t/p/out_len).
592
- * Format: `$argon2id$v=19$m=<m>,t=<t>,p=<p>$<22-char salt b64>$<hash b64>`.
593
- * The salt is always 16 bytes → 22 base64 chars (SaltString::encode_b64).
594
- * Pre-sizing exactly makes `passwordHash` a single native pass instead of a
595
- * grow-retry (which would re-run the whole ~tens-of-ms hash on a miss).
548
+ * Exact length of an argon2id PHC string for the given params
549
+ * (m/t/p/salt_len/out_len).
550
+ * Format: `$argon2id$v=19$m=<m>,t=<t>,p=<p>$<salt b64>$<hash b64>`.
551
+ * The salt b64 length is base64-no-pad of the CALLER's salt byte length
552
+ * (`SaltString::encode_b64`) — NOT a fixed 16 bytes. Hardcoding 22
553
+ * under-counts salts >16 bytes (e.g. the 18-byte "salty-salt-16bytes" →
554
+ * 24 chars) and growExact would re-run the whole ~tens-of-ms hash on the
555
+ * retry (measured 2× slowdown). Pre-sizing exactly keeps `passwordHash` a
556
+ * single native pass.
596
557
  */
597
- function argon2PhcLength(m: number, t: number, p: number, outLen: number): number {
558
+ function argon2PhcLength(m: number, t: number, p: number, saltLen: number, outLen: number): number {
598
559
  return (
599
560
  15 + // "$argon2id$v=19$"
600
561
  2 + // "m="
@@ -604,7 +565,7 @@ function argon2PhcLength(m: number, t: number, p: number, outLen: number): numbe
604
565
  3 + // ",p="
605
566
  String(p).length +
606
567
  1 + // "$" before the salt
607
- 22 + // salt b64 (16 bytes)
568
+ b64Len(saltLen) + // salt b64 (no-pad — depends on the caller's salt length)
608
569
  1 + // "$" before the hash
609
570
  b64Len(outLen)
610
571
  )
@@ -618,6 +579,7 @@ function build(
618
579
  const fnv = sym.castrum_fnv1a64 as (...a: unknown[]) => number | bigint
619
580
  const xxh = sym.castrum_xxh3 as (...a: unknown[]) => number | bigint
620
581
  const jsonValid = sym.castrum_json_valid as (...a: unknown[]) => number | bigint
582
+ const utf8Valid = sym.castrum_utf8_valid as (...a: unknown[]) => number | bigint
621
583
  const hexEncode = sym.castrum_hex_encode as Raw4
622
584
  const hexDecode = sym.castrum_hex_decode as Raw4
623
585
  const urlEncode = sym.castrum_url_encode as Raw4
@@ -635,19 +597,39 @@ function build(
635
597
  // JIT folds it away at the call site.
636
598
  const oneArg = (raw: (...a: unknown[]) => number | bigint, v: Uint8Array): number | bigint =>
637
599
  useBufferLength ? raw(v, v) : raw(v, v.length)
600
+ // Phase 1.1: under `buffer`/`buffer_length` the engine reads ptr + byteLength
601
+ // off the SAME view, so every `(ptr,len)` pair passes the view itself for the
602
+ // length slot (atomic snapshot, one JS arg fewer); under `(ptr,usize)` it's
603
+ // the explicit length. The branch is a bind-time constant → JIT folds it.
604
+ const lenOrView = (v: Uint8Array): Uint8Array | number => (useBufferLength ? v : v.length)
638
605
  const hmacVerify = sym.castrum_hmac_sha256_verify as Raw6
639
606
  const csrfVerify = sym.castrum_csrf_verify as Raw4
640
607
  const passwordVerify = sym.castrum_password_verify as Raw4
641
- const passwordVerifyBcrypt = sym.castrum_password_verify_bcrypt as Raw4
642
- const wsAcceptKey = sym.castrum_ws_accept_key as Raw4
643
- const etag = sym.castrum_etag as Raw5
644
- const randomToken = sym.castrum_random_token as Raw3
608
+ const passwordVerifyBcrypt = sym.castrum_password_verify_bcrypt as Raw3
609
+ const wsAcceptKey = sym.castrum_ws_accept_key as RawCStr
610
+ const wsAcceptKeyInto = sym.castrum_ws_accept_key_into as Raw4
611
+ const etagCStr = sym.castrum_etag as RawCStr
612
+ const etagIntoRaw = sym.castrum_etag_into as Raw5
613
+ const conditionalIsNotModifiedRaw = sym.castrum_conditional_is_not_modified as Raw6
614
+ const mediaTypeMatcherMatchesRaw = sym.castrum_media_type_matcher_matches as Raw3
615
+ const acceptNegotiatorNegotiateRaw = sym.castrum_accept_negotiator_negotiate as RawCStr
616
+ const jwtSignerSignRaw = sym.castrum_jwt_signer_sign as Raw6
617
+ const jwtSignerVerifyRaw = sym.castrum_jwt_signer_verify as Raw6
618
+ const templateRenderRaw = sym.castrum_template_render as Raw5
619
+ const schemaValidatorValidateRaw = sym.castrum_schema_validator_validate as Raw3
620
+ const rateLimiterCheckRaw = sym.castrum_rate_limiter_check as Raw5
621
+ const rateLimiterCheckKeyRaw = sym.castrum_rate_limiter_check_key as Raw5
622
+ const randomToken = sym.castrum_random_token as RawCStr
623
+ const randomTokenInto = sym.castrum_random_token_into as Raw3
645
624
  const base64Encode = sym.castrum_base64_encode as Raw6
646
625
  const base64Decode = sym.castrum_base64_decode as Raw6
647
626
  const hmacSha256 = sym.castrum_hmac_sha256 as Raw6
648
- const signCookie = sym.castrum_sign_cookie as Raw6
649
- const verifyCookie = sym.castrum_verify_cookie as Raw6
650
- const csrfToken = sym.castrum_csrf_token as Raw4
627
+ const signCookie = sym.castrum_sign_cookie as RawCStr
628
+ const signCookieInto = sym.castrum_sign_cookie_into as Raw6
629
+ const verifyCookie = sym.castrum_verify_cookie as RawCStr
630
+ const verifyCookieInto = sym.castrum_verify_cookie_into as Raw6
631
+ const csrfToken = sym.castrum_csrf_token as RawCStr
632
+ const csrfTokenInto = sym.castrum_csrf_token_into as Raw4
651
633
  const passwordHash = sym.castrum_password_hash as Raw10
652
634
  const passwordHashBcrypt = sym.castrum_password_hash_bcrypt as Raw5
653
635
  const pbkdf2 = sym.castrum_pbkdf2_sha256 as Raw8
@@ -663,12 +645,29 @@ function build(
663
645
  const httpParsePacked = sym.castrum_http_parse_request_packed as Raw4
664
646
  const queryParsePacked = sym.castrum_query_parse_packed as Raw4
665
647
  const cookieParsePacked = sym.castrum_cookie_parse_packed as Raw4
666
- const jwtSignBytes = sym.castrum_jwt_sign_bytes as Raw8
648
+ const httpDateIntoRaw = sym.castrum_http_date_into as Raw3
649
+ const sseEncodeIntoRaw = sym.castrum_sse_encode_into as Raw10
650
+ const jwtSignBytes = sym.castrum_jwt_sign_bytes as RawCStr
651
+ const jsonParsePackedSym = sym.castrum_json_parse_packed as Raw4
652
+ const jwtSignBytesInto = sym.castrum_jwt_sign_bytes_into as Raw8
667
653
  const wsFrameDecodePacked = sym.castrum_ws_frame_decode_packed as Raw4
668
654
  const multipartParsePacked = sym.castrum_multipart_parse_packed as Raw6
669
655
  const formParsePacked = sym.castrum_form_parse_packed as Raw4
656
+ // Phase 3 — stateless napi-only scalars.
657
+ const parseMediaTypeSym = sym.castrum_parse_media_type as Raw4
658
+ const parseHttpDateSym = sym.castrum_parse_http_date as Raw4
659
+ const parseAcceptEncodingSym = sym.castrum_parse_accept_encoding as Raw4
660
+ const urlEncodeQuerySym = sym.castrum_url_encode_query as (...a: unknown[]) => string | null
661
+ const urlResolveSym = sym.castrum_url_resolve as (...a: unknown[]) => string | null
662
+ const urlBuilderResolveRaw = sym.castrum_url_builder_resolve as Raw5
663
+ const mimeFromExtensionSym = sym.castrum_mime_from_extension as (...a: unknown[]) => string | null
664
+ const jwtVerifySym = sym.castrum_jwt_verify as (...a: unknown[]) => string | null
670
665
  const ingressHandlePacked = sym.castrum_ingress_handle_packed as Raw7
666
+ const ingressHandleComponentsSym = sym.castrum_ingress_handle_components as Raw12
671
667
  const ingressLayoutSym = sym.castrum_ingress_layout as Raw2
668
+ const routeCompileSym = sym.castrum_route_compile as Raw2
669
+ const routeRunSym = sym.castrum_route_run as Raw5
670
+ const routeDestroySym = sym.castrum_route_destroy as (a: unknown) => void
672
671
 
673
672
  // ── Encoder / decoder `Into` helpers ──────────────────────────────
674
673
  const hexEncodeInto = (input: Uint8Array, output: Uint8Array): number => {
@@ -677,11 +676,11 @@ function build(
677
676
  if (output.length < input.length * 2) {
678
677
  throw new Error('hex encode: output buffer too small')
679
678
  }
680
- return Number(hexEncode(input, input.length, output, output.length))
679
+ return Number(hexEncode(input, lenOrView(input), output, lenOrView(output)))
681
680
  }
682
681
 
683
682
  const urlEncodeInto = (input: Uint8Array, output: Uint8Array): number => {
684
- const w = Number(urlEncode(input, input.length, output, output.length))
683
+ const w = Number(urlEncode(input, lenOrView(input), output, lenOrView(output)))
685
684
  // Every input byte encodes to >= 1 output byte, so a 0 write on non-empty
686
685
  // input means the buffer was too small (napi throws there too). Empty
687
686
  // input legitimately writes 0.
@@ -692,12 +691,12 @@ function build(
692
691
  }
693
692
 
694
693
  const hexDecodeInto = (input: Uint8Array, output: Uint8Array): number => {
695
- const w = Number(hexDecode(input, input.length, output, output.length))
694
+ const w = Number(hexDecode(input, lenOrView(input), output, lenOrView(output)))
696
695
  return writeOrThrow(w, input.length, 'hex decode')
697
696
  }
698
697
 
699
698
  const urlDecodeInto = (input: Uint8Array, output: Uint8Array): number => {
700
- const w = Number(urlDecode(input, input.length, output, output.length))
699
+ const w = Number(urlDecode(input, lenOrView(input), output, lenOrView(output)))
701
700
  return writeOrThrow(w, input.length, 'url decode')
702
701
  }
703
702
 
@@ -711,9 +710,9 @@ function build(
711
710
  const w = Number(
712
711
  base64Decode(
713
712
  input,
714
- input.length,
713
+ lenOrView(input),
715
714
  output,
716
- output.length,
715
+ lenOrView(output),
717
716
  flag(urlSafe),
718
717
  flag(padding ?? true),
719
718
  ),
@@ -731,9 +730,9 @@ function build(
731
730
  const w = Number(
732
731
  base64Encode(
733
732
  input,
734
- input.length,
733
+ lenOrView(input),
735
734
  output,
736
- output.length,
735
+ lenOrView(output),
737
736
  flag(urlSafe),
738
737
  flag(padding ?? true),
739
738
  ),
@@ -743,60 +742,110 @@ function build(
743
742
  return writeOrThrow(w, input.length, 'base64 encode')
744
743
  }
745
744
 
745
+ // cstring-return (the engine clones the NUL-terminated string at call time)
746
+ // → the JS string directly (native transfer — zero encode); a `null` return
747
+ // is the C failure sentinel → throw.
748
+ const cstr = (s: string | null, label: string): string => {
749
+ if (s === null) throw new Error(label)
750
+ return s
751
+ }
752
+
746
753
  const etagInto = (data: Uint8Array, output: Uint8Array, weak?: boolean): number => {
747
- const w = Number(etag(data, data.length, output, output.length, flag(weak)))
748
- if (w === 0 && data.length !== 0) {
754
+ // Native pooled `_into`: writes 10/12 bytes directly into the caller
755
+ // buffer (no cstring round-trip). Needed-size convention: a write larger
756
+ // than `output.length` reports the exact required size → throw.
757
+ const w = Number(etagIntoRaw(data, lenOrView(data), flag(weak), output, lenOrView(output)))
758
+ if (w === 0) {
759
+ throw new Error('etag: invalid input')
760
+ }
761
+ if (w > output.length) {
749
762
  throw new Error('etag: output buffer too small')
750
763
  }
751
764
  return w
752
765
  }
753
766
 
767
+ // Pooled 9-byte scratch for jsonSumIds (the packed `[u8 ok][i64 sum LE]`
768
+ // output). The wrapper returns the BigInt VALUE (no byte aliasing escapes),
769
+ // so a single reused buffer + cached DataView is safe — removes the per-call
770
+ // Uint8Array + DataView allocs (measured ~400ns of the FFI wrapper cost).
771
+ const jsonSumOut = new Uint8Array(9)
772
+ const jsonSumView = new DataView(jsonSumOut.buffer)
773
+
774
+ // ── Reusable scratch for the STRING-returning encode wrappers ────────
775
+ // Each wrapper writes its result and decodes it into an immutable JS string
776
+ // (or primitive) SYNCHRONOUSLY before the next call reuses the buffer — the
777
+ // same safety rationale as `jsonSumOut` / `generateRequestId`. Per-Worker
778
+ // module state (single-threaded event loop); never shared across Workers.
779
+ // NOT used for byte-returning ops (hexDecode/urlDecode/base64Decode/aead/
780
+ // wsFrame/packed parsers): their returned `Uint8Array` aliases the buffer
781
+ // and escapes to the caller — those stay on `*Into` (caller-owned).
782
+ let encodeScratch = new Uint8Array(0)
783
+ const scratchFor = (size: number): Uint8Array => {
784
+ if (encodeScratch.byteLength < size) encodeScratch = new Uint8Array(size)
785
+ return encodeScratch
786
+ }
787
+ // Fixed-size packed-verdict scratch + cached DataViews (parseHttpDate 9 B,
788
+ // rate-limit check 13 B) — same pooled pattern as `jsonSumView`. Kept
789
+ // separate from the growable `encodeScratch` so the cached DataViews are
790
+ // never invalidated by a buffer replacement.
791
+ const dateScratch = new Uint8Array(9)
792
+ const dateScratchView = new DataView(dateScratch.buffer)
793
+ const rateScratch = new Uint8Array(13)
794
+ const rateScratchView = new DataView(rateScratch.buffer)
795
+
754
796
  return {
755
797
  crc32: (input) => Number(oneArg(crc32, input)) >>> 0,
756
798
  fnv1a64: (input) => BigInt(oneArg(fnv, input)),
757
799
  xxh3: (input) => BigInt(oneArg(xxh, input)),
758
800
  jsonValid: (input) => Number(oneArg(jsonValid, input)) === 1,
801
+ utf8Valid: (input) => Number(oneArg(utf8Valid, input)) === 1,
759
802
  hexEncode(input) {
760
- const out = new Uint8Array(input.length * 2)
803
+ // Pooled scratch — decoded synchronously to an immutable string (safe;
804
+ // removes the per-call `new Uint8Array(len*2)`). ALWAYS decode the
805
+ // written subarray: the shared scratch may be larger than `w` (grown by
806
+ // an earlier op), so decoding the whole buffer would read stale bytes.
807
+ const out = scratchFor(input.length * 2)
761
808
  const w = hexEncodeInto(input, out)
762
- return w === input.length * 2 ? out : out.subarray(0, w)
809
+ return decodeUtf8(out.subarray(0, w))
763
810
  },
764
811
  hexEncodeInto,
765
812
  urlEncode(input) {
766
- // RFC 3986 worst case is 3 bytes per input byte (`%XX`).
767
- const out = new Uint8Array(input.length * 3)
813
+ // RFC 3986 worst case is 3 bytes per input byte (`%XX`). Pooled scratch
814
+ // (decoded synchronously — safe).
815
+ const out = scratchFor(input.length * 3)
768
816
  const w = urlEncodeInto(input, out)
769
- return out.subarray(0, w)
817
+ return decodeUtf8(out.subarray(0, w))
770
818
  },
771
819
  urlEncodeInto,
772
820
 
773
- validateEmail: (input) => Number(oneArg(validateEmail, input)) === 1,
774
- validateUuid: (input) => Number(oneArg(validateUuid, input)) === 1,
775
- validateIpv4: (input) => Number(oneArg(validateIpv4, input)) === 1,
776
- validateIpv6: (input) => Number(oneArg(validateIpv6, input)) === 1,
821
+ validateEmail: (input) => Number(validateEmail(input)) === 1,
822
+ validateUuid: (input) => Number(validateUuid(input)) === 1,
823
+ validateIpv4: (input) => Number(validateIpv4(input)) === 1,
824
+ validateIpv6: (input) => Number(validateIpv6(input)) === 1,
777
825
  jsonSumIds: (input) => {
778
826
  // Packed [u8 ok][i64 sum LE] output (9 B): ok=1 → valid array (the sum
779
827
  // may be 0); ok=0 → invalid input. Bytes written: 9/1/0 (0 = real error).
780
- const out = new Uint8Array(9)
781
- const w = Number(jsonSumRaw(input, input.length, out, out.length))
828
+ const w = Number(jsonSumRaw(input, lenOrView(input), jsonSumOut, lenOrView(jsonSumOut)))
782
829
  if (w === 0) {
783
830
  throw new Error('json sum ids: output buffer too small')
784
831
  }
785
- if (out[0] === 0) {
832
+ if (jsonSumOut[0] === 0) {
786
833
  // Mirrors the napi error phrasing (serde: "expected an array of objects
787
834
  // with numeric ids") so both transports throw the same message.
788
835
  throw new Error('json sum ids: expected an array of objects with numeric ids')
789
836
  }
790
- return new DataView(out.buffer, out.byteOffset, out.byteLength).getBigInt64(1, true)
837
+ return jsonSumView.getBigInt64(1, true)
791
838
  },
792
839
  hmacSha256Verify: (key, data, signature) =>
793
- Number(hmacVerify(key, key.length, data, data.length, signature, signature.length)) === 1,
840
+ Number(
841
+ hmacVerify(key, lenOrView(key), data, lenOrView(data), signature, lenOrView(signature)),
842
+ ) === 1,
794
843
  csrfVerify: (token, secret) =>
795
- Number(csrfVerify(token, token.length, secret, secret.length)) === 1,
844
+ Number(csrfVerify(token, lenOrView(token), secret, lenOrView(secret))) === 1,
796
845
  passwordVerify: (password, phc) =>
797
- Number(passwordVerify(password, password.length, phc, phc.length)) === 1,
846
+ Number(passwordVerify(password, lenOrView(password), phc, lenOrView(phc))) === 1,
798
847
  passwordVerifyBcrypt: (password, phc) =>
799
- Number(passwordVerifyBcrypt(password, password.length, phc, phc.length)) === 1,
848
+ Number(passwordVerifyBcrypt(password, lenOrView(password), phc)) === 1,
800
849
 
801
850
  hexDecode(input) {
802
851
  const out = new Uint8Array(Math.floor(input.length / 2))
@@ -818,121 +867,291 @@ function build(
818
867
  base64DecodeInto,
819
868
 
820
869
  wsAcceptKey(key) {
821
- const out = new Uint8Array(28)
822
- const w = Number(wsAcceptKey(key, key.length, out, out.length))
870
+ return cstr(wsAcceptKey(key), 'ws accept key: bad key')
871
+ },
872
+ wsAcceptKeyInto(key, output) {
873
+ // Native pooled `_into`: writes the 28-byte accept key directly into the
874
+ // caller buffer (no cstring round-trip). Needed-size convention.
875
+ const w = Number(wsAcceptKeyInto(key, lenOrView(key), output, lenOrView(output)))
823
876
  if (w === 0) {
824
- throw new Error('ws accept key: bad key or output buffer too small')
877
+ throw new Error('ws accept key: bad key')
825
878
  }
826
- return out.subarray(0, w)
879
+ if (w > output.length) {
880
+ throw new Error('ws accept key: output buffer too small')
881
+ }
882
+ return w
827
883
  },
828
884
  etag(data, weak) {
829
- const out = new Uint8Array(12)
830
- const w = etagInto(data, out, weak)
831
- return out.subarray(0, w)
885
+ // cstring return (10 strong / 12 weak chars) — zero encode.
886
+ return cstr(etagCStr(data, lenOrView(data), flag(weak)), 'etag: invalid input')
832
887
  },
833
888
  etagInto,
834
- randomToken(byteLen) {
835
- const out = new Uint8Array(byteLen * 2)
836
- const w = Number(randomToken(byteLen, out, out.length))
837
- // byteLen 0 legitimately yields an empty token (napi returns empty too);
838
- // any other 0 is a real error (buffer too small / random source failed).
839
- if (w === 0 && byteLen !== 0) {
840
- throw new Error('random token: output buffer too small or random source failed')
889
+ conditionalIsNotModified(inner, ifNoneMatch, ifModifiedSince) {
890
+ // Opaque-handle eval of the precompiled `ConditionalRequest` state.
891
+ // flags bit0 = If-None-Match present, bit1 = If-Modified-Since present
892
+ // (present-but-empty is distinct from absent — napi Option parity). A
893
+ // null handle (0) → 0 (never dereferences freed state).
894
+ const flags = (ifNoneMatch === null ? 0 : 1) | (ifModifiedSince === null ? 0 : 2)
895
+ const inm = ifNoneMatch ?? EMPTY_VIEW
896
+ const ims = ifModifiedSince ?? EMPTY_VIEW
897
+ return (
898
+ Number(
899
+ conditionalIsNotModifiedRaw(inner, inm, lenOrView(inm), ims, lenOrView(ims), flags),
900
+ ) === 1
901
+ )
902
+ },
903
+ mediaTypeMatcherMatches(inner, actual) {
904
+ // Precompiled expected-type match → u8.
905
+ return Number(mediaTypeMatcherMatchesRaw(inner, actual, lenOrView(actual))) === 1
906
+ },
907
+ acceptNegotiatorNegotiate(inner, header) {
908
+ // cstring best-supported encoding; `null` = identity (napi Option parity).
909
+ return acceptNegotiatorNegotiateRaw(inner, header, lenOrView(header))
910
+ },
911
+ jwtSignerSign(inner, claimsJson, nowSeconds) {
912
+ // Precompiled key + ttl → compact token. 0 = invalid claims JSON (real
913
+ // error → growExact throws); w > output.length = exact needed size.
914
+ return growExact(
915
+ (out) =>
916
+ Number(
917
+ jwtSignerSignRaw(
918
+ inner,
919
+ claimsJson,
920
+ lenOrView(claimsJson),
921
+ BigInt(nowSeconds),
922
+ out,
923
+ lenOrView(out),
924
+ ),
925
+ ),
926
+ Math.min(claimsJson.length + 128, 64 * 1024),
927
+ 1024 * 1024,
928
+ 'jwt signer: invalid claims JSON or output buffer too small',
929
+ )
930
+ },
931
+ jwtSignerVerify(inner, token, nowSeconds) {
932
+ // Precompiled key → claims JSON bytes; 0 = invalid / expired → null.
933
+ const out = new Uint8Array(Math.min(token.length + 256, 64 * 1024))
934
+ const w = Number(
935
+ jwtSignerVerifyRaw(inner, token, lenOrView(token), BigInt(nowSeconds), out, lenOrView(out)),
936
+ )
937
+ if (w === 0) return null
938
+ if (w > out.length) {
939
+ const out2 = new Uint8Array(w)
940
+ const w2 = Number(
941
+ jwtSignerVerifyRaw(
942
+ inner,
943
+ token,
944
+ lenOrView(token),
945
+ BigInt(nowSeconds),
946
+ out2,
947
+ lenOrView(out2),
948
+ ),
949
+ )
950
+ return w2 === 0 ? null : out2.subarray(0, w2)
841
951
  }
842
952
  return out.subarray(0, w)
843
953
  },
954
+ templateRender(inner, contextJson) {
955
+ // Compiled template + pre-serialized JSON context → UTF-8 bytes. 0 =
956
+ // invalid context / render error (real error → growExact throws).
957
+ return growExact(
958
+ (out) =>
959
+ Number(
960
+ templateRenderRaw(inner, contextJson, lenOrView(contextJson), out, lenOrView(out)),
961
+ ),
962
+ Math.min(contextJson.length + 128, 64 * 1024),
963
+ 1024 * 1024,
964
+ 'template render: invalid context JSON or render failed',
965
+ )
966
+ },
967
+ schemaValidatorValidate(inner, doc) {
968
+ return Number(schemaValidatorValidateRaw(inner, doc, lenOrView(doc))) === 1
969
+ },
970
+ rateLimiterCheck(inner, key, nowMs) {
971
+ // Packed [u8 allowed][u32 remaining LE][i64 reset_ms LE] (13 bytes).
972
+ // Reused scratch + cached DataView (no per-call allocs). `key` is a
973
+ // `cstring` ARG (the engine transcodes the JS string in-engine).
974
+ const out = rateScratch
975
+ const w = Number(
976
+ rateLimiterCheckRaw(inner, key, BigInt(Math.trunc(nowMs)), out, lenOrView(out)),
977
+ )
978
+ if (w === 0) throw new Error('rate limiter check: null handle')
979
+ return unpackRateCheck(out, rateScratchView)
980
+ },
981
+ rateLimiterCheckKey(inner, key, nowMs) {
982
+ // Packed [u8 allowed][u32 remaining LE][i64 reset_ms LE] (13 bytes).
983
+ // Reused scratch + cached DataView (no per-call allocs).
984
+ const out = rateScratch
985
+ const w = Number(
986
+ rateLimiterCheckKeyRaw(inner, BigInt(key), BigInt(Math.trunc(nowMs)), out, lenOrView(out)),
987
+ )
988
+ if (w === 0) throw new Error('rate limiter check: null handle')
989
+ return unpackRateCheck(out, rateScratchView)
990
+ },
991
+ randomToken(byteLen) {
992
+ // cstring return of `byteLen*2` hex chars; byteLen 0 → empty string → empty
993
+ // Uint8Array (napi returns empty too). null = random source failed / >16MiB.
994
+ return cstr(
995
+ randomToken(byteLen),
996
+ 'random token: output buffer too small or random source failed',
997
+ )
998
+ },
999
+ randomTokenInto(byteLen, output) {
1000
+ // Pooled sibling: native writes `byteLen*2` hex chars directly into the
1001
+ // caller buffer (no cstring round-trip). Needed-size convention: a write
1002
+ // larger than `output.length` reports the exact required size → throw
1003
+ // (the caller owns the buffer); 0 = real error (cap / RNG).
1004
+ const w = Number(randomTokenInto(byteLen, output, lenOrView(output)))
1005
+ if (w === 0) {
1006
+ throw new Error('random token: random source failed or byteLen exceeds 16 MiB')
1007
+ }
1008
+ if (w > output.length) {
1009
+ throw new Error('random token: output buffer too small')
1010
+ }
1011
+ return w
1012
+ },
844
1013
  base64Encode(input, urlSafe, padding) {
845
- const out = new Uint8Array(Math.ceil(input.length / 3) * 4)
1014
+ // Pooled scratch — decoded synchronously to an immutable string (safe).
1015
+ const out = scratchFor(Math.ceil(input.length / 3) * 4)
846
1016
  const w = base64EncodeInto(input, out, urlSafe, padding)
847
- return out.subarray(0, w)
1017
+ return decodeUtf8(out.subarray(0, w))
848
1018
  },
849
1019
  base64EncodeInto,
850
1020
  hmacSha256(key, data) {
851
- const out = new Uint8Array(64)
852
- const w = Number(hmacSha256(key, key.length, data, data.length, out, out.length))
1021
+ // Pooled 64-byte scratch — decoded synchronously (safe).
1022
+ const out = scratchFor(64)
1023
+ const w = Number(hmacSha256(key, lenOrView(key), data, lenOrView(data), out, lenOrView(out)))
853
1024
  if (w === 0) {
854
1025
  throw new Error('hmac sha256: output buffer too small')
855
1026
  }
856
- return out.subarray(0, w)
1027
+ return decodeUtf8(out.subarray(0, w))
857
1028
  },
858
1029
  hmacSha256Into(key, data, output) {
859
1030
  if (output.length < 64) {
860
1031
  throw new Error('hmac sha256: output buffer too small')
861
1032
  }
862
- const w = Number(hmacSha256(key, key.length, data, data.length, output, output.length))
1033
+ const w = Number(
1034
+ hmacSha256(key, lenOrView(key), data, lenOrView(data), output, lenOrView(output)),
1035
+ )
863
1036
  if (w === 0) {
864
1037
  throw new Error('hmac sha256: output buffer too small')
865
1038
  }
866
1039
  return w
867
1040
  },
868
1041
  signCookie(value, secret) {
869
- // `value.<64-hex>` → value.length + 1 + 64.
870
- const out = new Uint8Array(value.length + 65)
871
- const w = Number(signCookie(value, value.length, secret, secret.length, out, out.length))
872
- if (w === 0) {
873
- throw new Error('sign cookie: output buffer too small')
874
- }
875
- return out.subarray(0, w)
1042
+ // `value.<64-hex>` returned as a cstring (value.length + 1 + 64 chars).
1043
+ return cstr(
1044
+ signCookie(value, lenOrView(value), secret, lenOrView(secret)),
1045
+ 'sign cookie: invalid input',
1046
+ )
876
1047
  },
877
1048
  signCookieInto(value, secret, output) {
878
- const need = value.length + 65
879
- if (output.length < need) {
880
- throw new Error('sign cookie: output buffer too small')
881
- }
1049
+ // Native pooled `_into`: writes `value.<64-hex>` directly into the caller
1050
+ // buffer (no cstring round-trip — this is why pooled sign_cookie was
1051
+ // previously a REGRESSION vs allocating). Needed-size convention.
882
1052
  const w = Number(
883
- signCookie(value, value.length, secret, secret.length, output, output.length),
1053
+ signCookieInto(
1054
+ value,
1055
+ lenOrView(value),
1056
+ secret,
1057
+ lenOrView(secret),
1058
+ output,
1059
+ lenOrView(output),
1060
+ ),
884
1061
  )
885
1062
  if (w === 0) {
1063
+ throw new Error('sign cookie: invalid input')
1064
+ }
1065
+ if (w > output.length) {
886
1066
  throw new Error('sign cookie: output buffer too small')
887
1067
  }
888
1068
  return w
889
1069
  },
890
1070
  verifyCookie(signed, secret) {
891
- const out = new Uint8Array(signed.length)
892
- const w = Number(verifyCookie(signed, signed.length, secret, secret.length, out, out.length))
893
- return w === 0 ? null : out.subarray(0, w)
1071
+ // cstring return; `null` = invalid signature / malformed → null (napi parity).
1072
+ // CAVEAT (refactor): the engine clones a UTF-8 string, so a cookie VALUE
1073
+ // containing non-UTF-8 bytes or NUL cannot round-trip byte-faithfully on
1074
+ // the FFI path (napi still does). Signed values are ASCII in practice.
1075
+ const s = verifyCookie(signed, lenOrView(signed), secret, lenOrView(secret))
1076
+ return s === null ? null : s
1077
+ },
1078
+ verifyCookieInto(signed, secret, output) {
1079
+ // Native pooled `_into`: writes the verified value directly into the
1080
+ // caller buffer. 0 = invalid signature / malformed → null (napi parity,
1081
+ // like the allocating `verifyCookie`); a write larger than `output.length`
1082
+ // reports the exact required size → throw.
1083
+ const w = Number(
1084
+ verifyCookieInto(
1085
+ signed,
1086
+ lenOrView(signed),
1087
+ secret,
1088
+ lenOrView(secret),
1089
+ output,
1090
+ lenOrView(output),
1091
+ ),
1092
+ )
1093
+ if (w === 0) {
1094
+ return null
1095
+ }
1096
+ if (w > output.length) {
1097
+ throw new Error('verify cookie: output buffer too small')
1098
+ }
1099
+ return w
894
1100
  },
895
1101
  csrfToken(secret) {
896
- const out = new Uint8Array(129)
897
- const w = Number(csrfToken(secret, secret.length, out, out.length))
1102
+ // 129-char `hex.hex` cstring return.
1103
+ return cstr(
1104
+ csrfToken(secret, lenOrView(secret)),
1105
+ 'csrf token: output buffer too small or random source failed',
1106
+ )
1107
+ },
1108
+ csrfTokenInto(secret, output) {
1109
+ // Native pooled `_into`: writes the 129-char `hex.hex` token directly
1110
+ // into the caller buffer (no cstring round-trip). Needed-size convention.
1111
+ const w = Number(csrfTokenInto(secret, lenOrView(secret), output, lenOrView(output)))
898
1112
  if (w === 0) {
899
- throw new Error('csrf token: output buffer too small or random source failed')
1113
+ throw new Error('csrf token: random source failed')
900
1114
  }
901
- return out.subarray(0, w)
1115
+ if (w > output.length) {
1116
+ throw new Error('csrf token: output buffer too small')
1117
+ }
1118
+ return w
902
1119
  },
903
1120
  passwordHash(password, salt, mCost, tCost, pCost, outLen) {
904
1121
  // Pre-size with the EXACT PHC string length (computable from the params),
905
1122
  // so the hash runs once — a grow-retry would re-run the whole argon2
906
1123
  // hash on a miss. growExact remains the safety net.
907
- return growExact(
908
- (out) =>
909
- Number(
910
- passwordHash(
911
- password,
912
- password.length,
913
- salt,
914
- salt.length,
915
- mCost,
916
- tCost,
917
- pCost,
918
- outLen,
919
- out,
920
- out.length,
1124
+ return decodeUtf8(
1125
+ growExact(
1126
+ (out) =>
1127
+ Number(
1128
+ passwordHash(
1129
+ password,
1130
+ lenOrView(password),
1131
+ salt,
1132
+ lenOrView(salt),
1133
+ mCost,
1134
+ tCost,
1135
+ pCost,
1136
+ outLen,
1137
+ out,
1138
+ lenOrView(out),
1139
+ ),
921
1140
  ),
922
- ),
923
- argon2PhcLength(mCost, tCost, pCost, outLen),
924
- 2 * 1024 * 1024,
925
- 'password hash: output buffer too small',
1141
+ argon2PhcLength(mCost, tCost, pCost, salt.length, outLen),
1142
+ 2 * 1024 * 1024,
1143
+ 'password hash: output buffer too small',
1144
+ ),
926
1145
  )
927
1146
  },
928
1147
  passwordHashBcrypt(password, cost) {
929
1148
  // `$2b$CC$` + 22 salt chars + 31 hash chars = 60 chars.
930
1149
  const out = new Uint8Array(64)
931
- const w = Number(passwordHashBcrypt(password, password.length, cost, out, out.length))
1150
+ const w = Number(passwordHashBcrypt(password, lenOrView(password), cost, out, lenOrView(out)))
932
1151
  if (w === 0) {
933
1152
  throw new Error('password hash bcrypt: output buffer too small')
934
1153
  }
935
- return out.subarray(0, w)
1154
+ return decodeUtf8(out.subarray(0, w))
936
1155
  },
937
1156
  pbkdf2Sha256(password, salt, rounds, dkLen) {
938
1157
  // Rust clamps dkLen to [1, 1MiB] (PBKDF2_MIN_LEN/MAX_LEN) AFTER sizing its
@@ -940,7 +1159,16 @@ function build(
940
1159
  const dk = Math.min(Math.max(dkLen, 1), 1024 * 1024)
941
1160
  const out = new Uint8Array(dk)
942
1161
  const w = Number(
943
- pbkdf2(password, password.length, salt, salt.length, rounds, dkLen, out, out.length),
1162
+ pbkdf2(
1163
+ password,
1164
+ lenOrView(password),
1165
+ salt,
1166
+ lenOrView(salt),
1167
+ rounds,
1168
+ dkLen,
1169
+ out,
1170
+ lenOrView(out),
1171
+ ),
944
1172
  )
945
1173
  if (w === 0) {
946
1174
  throw new Error('pbkdf2: output buffer too small')
@@ -953,14 +1181,14 @@ function build(
953
1181
  const w = Number(
954
1182
  aeadEncrypt(
955
1183
  key,
956
- key.length,
1184
+ lenOrView(key),
957
1185
  nonce,
958
- nonce.length,
1186
+ lenOrView(nonce),
959
1187
  plaintext,
960
- plaintext.length,
1188
+ lenOrView(plaintext),
961
1189
  algorithm,
962
1190
  out,
963
- out.length,
1191
+ lenOrView(out),
964
1192
  ),
965
1193
  )
966
1194
  if (w === 0) {
@@ -976,14 +1204,14 @@ function build(
976
1204
  const w = Number(
977
1205
  aeadEncrypt(
978
1206
  key,
979
- key.length,
1207
+ lenOrView(key),
980
1208
  nonce,
981
- nonce.length,
1209
+ lenOrView(nonce),
982
1210
  plaintext,
983
- plaintext.length,
1211
+ lenOrView(plaintext),
984
1212
  algorithm,
985
1213
  output,
986
- output.length,
1214
+ lenOrView(output),
987
1215
  ),
988
1216
  )
989
1217
  if (w === 0) {
@@ -996,14 +1224,14 @@ function build(
996
1224
  const w = Number(
997
1225
  aeadDecrypt(
998
1226
  key,
999
- key.length,
1227
+ lenOrView(key),
1000
1228
  nonce,
1001
- nonce.length,
1229
+ lenOrView(nonce),
1002
1230
  ciphertext,
1003
- ciphertext.length,
1231
+ lenOrView(ciphertext),
1004
1232
  algorithm,
1005
1233
  out,
1006
- out.length,
1234
+ lenOrView(out),
1007
1235
  ),
1008
1236
  )
1009
1237
  return w === 0 ? null : out.subarray(0, w)
@@ -1013,7 +1241,15 @@ function build(
1013
1241
  // Header (max 10) + payload + mask key (4).
1014
1242
  const out = new Uint8Array(payload.length + 14)
1015
1243
  const w = Number(
1016
- wsFrameEncode(opcode, payload, payload.length, flag(mask), flag(fin), out, out.length),
1244
+ wsFrameEncode(
1245
+ opcode,
1246
+ payload,
1247
+ lenOrView(payload),
1248
+ flag(mask),
1249
+ flag(fin),
1250
+ out,
1251
+ lenOrView(out),
1252
+ ),
1017
1253
  )
1018
1254
  if (w === 0) {
1019
1255
  throw new Error('ws frame encode: output buffer too small')
@@ -1030,11 +1266,11 @@ function build(
1030
1266
  wsFrameEncode(
1031
1267
  opcode,
1032
1268
  payload,
1033
- payload.length,
1269
+ lenOrView(payload),
1034
1270
  flag(mask),
1035
1271
  flag(fin),
1036
1272
  output,
1037
- output.length,
1273
+ lenOrView(output),
1038
1274
  ),
1039
1275
  )
1040
1276
  if (w === 0) {
@@ -1043,11 +1279,14 @@ function build(
1043
1279
  return w
1044
1280
  },
1045
1281
  jsonPatch(doc, patch) {
1046
- return growExact(
1047
- (out) => Number(jsonPatch(doc, doc.length, patch, patch.length, out, out.length)),
1048
- Math.min(Math.max(doc.length, patch.length) + 16, 64 * 1024),
1049
- MAX_JSON_PATCH_OUTPUT,
1050
- 'json patch: output buffer too small or patch inapplicable',
1282
+ return decodeUtf8(
1283
+ growExact(
1284
+ (out) =>
1285
+ Number(jsonPatch(doc, lenOrView(doc), patch, lenOrView(patch), out, lenOrView(out))),
1286
+ Math.min(Math.max(doc.length, patch.length) + 16, 64 * 1024),
1287
+ MAX_JSON_PATCH_OUTPUT,
1288
+ 'json patch: output buffer too small or patch inapplicable',
1289
+ ),
1051
1290
  )
1052
1291
  },
1053
1292
  gzipCompress(data, level = 6) {
@@ -1058,14 +1297,17 @@ function build(
1058
1297
  // single-pass). growExact handles incompressible data with at most one
1059
1298
  // exact retry (no re-run loop).
1060
1299
  return growExact(
1061
- (out) => Number(gzipCompress(data, data.length, Math.min(level, 9), out, out.length)),
1300
+ (out) =>
1301
+ Number(gzipCompress(data, lenOrView(data), Math.min(level, 9), out, lenOrView(out))),
1062
1302
  Math.min(data.length + COMPRESS_HEADROOM, COMPRESS_INITIAL_CAP),
1063
1303
  Math.max(data.length * 2 + COMPRESS_HEADROOM, COMPRESS_MAX_CAP),
1064
1304
  'gzip compress: output buffer too small',
1065
1305
  )
1066
1306
  },
1067
1307
  gzipCompressInto(data, output, level = 6) {
1068
- const w = Number(gzipCompress(data, data.length, Math.min(level, 9), output, output.length))
1308
+ const w = Number(
1309
+ gzipCompress(data, lenOrView(data), Math.min(level, 9), output, lenOrView(output)),
1310
+ )
1069
1311
  // Needed-size convention: w > output.length = too small; w === 0 = real error.
1070
1312
  if (w === 0) {
1071
1313
  throw new Error('gzip compress: invalid input')
@@ -1082,23 +1324,40 @@ function build(
1082
1324
  // back to a multiplier guess. growExact handles any residual miss with
1083
1325
  // one exact retry, and invalid input throws immediately (no 64 MiB
1084
1326
  // grow-to-max allocation storm).
1085
- const isize = Number(gzipIsize(data, data.length))
1327
+ const isize = Number(gzipIsize(data, lenOrView(data)))
1086
1328
  const initial =
1087
1329
  isize !== 0
1088
1330
  ? Math.min(isize, max)
1089
1331
  : Math.min(data.length * DECOMPRESS_GUESS_MULTIPLIER_GZIP, DECOMPRESS_FALLBACK_CAP)
1090
1332
  return growExact(
1091
- (out) => Number(gzipDecompress(data, data.length, max, out, out.length)),
1333
+ (out) => Number(gzipDecompress(data, lenOrView(data), max, out, lenOrView(out))),
1092
1334
  initial,
1093
1335
  max,
1094
1336
  'gzip decompress: invalid stream or exceeded max decompressed size',
1095
1337
  )
1096
1338
  },
1339
+ gzipDecompressInto(data, output, maxDecompressed) {
1340
+ // Pooled sibling — the C ABI streams the decompressed output directly
1341
+ // into the caller buffer via the `_into` core (no internal Vec), keeping
1342
+ // the 64 MiB decompression-bomb cap. Needed-size convention: w === 0 =
1343
+ // real error (invalid stream / cap exceeded); w > output.length = exact
1344
+ // required size → throw (nothing to grow — the caller owns the buffer).
1345
+ const max = maxDecompressed ?? MAX_DECOMPRESSED
1346
+ const w = Number(gzipDecompress(data, lenOrView(data), max, output, lenOrView(output)))
1347
+ if (w === 0) {
1348
+ throw new Error('gzip decompress: invalid stream or exceeded max decompressed size')
1349
+ }
1350
+ if (w > output.length) {
1351
+ throw new Error('gzip decompress: output buffer too small')
1352
+ }
1353
+ return w
1354
+ },
1097
1355
  brotliCompress(data, quality = 5) {
1098
1356
  // Same cap rationale as gzipCompress (streaming core); growExact for
1099
1357
  // incompressible data.
1100
1358
  return growExact(
1101
- (out) => Number(brotliCompress(data, data.length, Math.min(quality, 11), out, out.length)),
1359
+ (out) =>
1360
+ Number(brotliCompress(data, lenOrView(data), Math.min(quality, 11), out, lenOrView(out))),
1102
1361
  Math.min(data.length + COMPRESS_HEADROOM, COMPRESS_INITIAL_CAP),
1103
1362
  Math.max(data.length * 2 + COMPRESS_HEADROOM, COMPRESS_MAX_CAP),
1104
1363
  'brotli compress: output buffer too small',
@@ -1106,7 +1365,7 @@ function build(
1106
1365
  },
1107
1366
  brotliCompressInto(data, output, quality = 5) {
1108
1367
  const w = Number(
1109
- brotliCompress(data, data.length, Math.min(quality, 11), output, output.length),
1368
+ brotliCompress(data, lenOrView(data), Math.min(quality, 11), output, lenOrView(output)),
1110
1369
  )
1111
1370
  // Needed-size convention: w > output.length = too small; w === 0 = real error.
1112
1371
  if (w === 0) {
@@ -1124,7 +1383,7 @@ function build(
1124
1383
  // cap bounds over-allocation on large streams; growExact handles
1125
1384
  // higher-ratio streams with one exact retry.
1126
1385
  return growExact(
1127
- (out) => Number(brotliDecompress(data, data.length, max, out, out.length)),
1386
+ (out) => Number(brotliDecompress(data, lenOrView(data), max, out, lenOrView(out))),
1128
1387
  Math.min(
1129
1388
  Math.max(data.length * DECOMPRESS_GUESS_MULTIPLIER_BROTLI, DECOMPRESS_MIN_INITIAL),
1130
1389
  DECOMPRESS_FALLBACK_CAP,
@@ -1133,24 +1392,41 @@ function build(
1133
1392
  'brotli decompress: invalid stream or exceeded max decompressed size',
1134
1393
  )
1135
1394
  },
1395
+ brotliDecompressInto(data, output, maxDecompressed) {
1396
+ // Pooled sibling — streams into the caller buffer, keeps the 64 MiB cap
1397
+ // (same convention as gzipDecompressInto above).
1398
+ const max = maxDecompressed ?? MAX_DECOMPRESSED
1399
+ const w = Number(brotliDecompress(data, lenOrView(data), max, output, lenOrView(output)))
1400
+ if (w === 0) {
1401
+ throw new Error('brotli decompress: invalid stream or exceeded max decompressed size')
1402
+ }
1403
+ if (w > output.length) {
1404
+ throw new Error('brotli decompress: output buffer too small')
1405
+ }
1406
+ return w
1407
+ },
1136
1408
 
1137
1409
  httpParseRequestPackedInto(input, output) {
1138
- const w = Number(httpParsePacked(input, input.length, output, output.length))
1410
+ const w = Number(httpParsePacked(input, lenOrView(input), output, lenOrView(output)))
1139
1411
  if (w === 0 && input.length !== 0) {
1140
1412
  throw new Error('http parse: output buffer too small or malformed request')
1141
1413
  }
1142
1414
  return w
1143
1415
  },
1144
1416
  queryParsePackedInto(input, output) {
1145
- const w = Number(queryParsePacked(input, input.length, output, output.length))
1146
- if (w === 0 && input.length !== 0) {
1417
+ const w = Number(queryParsePacked(input, lenOrView(input), output, lenOrView(output)))
1418
+ // Needed-size convention: w > output.length = exact required size →
1419
+ // too-small (throw — the caller owns the buffer, nothing to grow);
1420
+ // w === 0 = real error (malformed %XX).
1421
+ if (w === 0 || w > output.length) {
1147
1422
  throw new Error('query parse: output buffer too small')
1148
1423
  }
1149
1424
  return w
1150
1425
  },
1151
1426
  cookieParsePackedInto(input, output) {
1152
- const w = Number(cookieParsePacked(input, input.length, output, output.length))
1153
- if (w === 0 && input.length !== 0) {
1427
+ const w = Number(cookieParsePacked(input, lenOrView(input), output, lenOrView(output)))
1428
+ // Needed-size convention (same as queryParsePackedInto).
1429
+ if (w === 0 || w > output.length) {
1154
1430
  throw new Error('cookie parse: output buffer too small')
1155
1431
  }
1156
1432
  return w
@@ -1158,71 +1434,283 @@ function build(
1158
1434
 
1159
1435
  // ── Excluded-surface additions ──────────────────────────────────
1160
1436
  jwtSignBytes(claimsJson, secret, ttl, now) {
1161
- // Token = header(~36) + payload (claims + iat/exp, base64url ≈ 4/3×) +
1162
- // signature (~43) + 2 dots. The old `claims.length + 128` under-sized a
1163
- // typical token (measured 168 B for a 30 B claim → grow-retry double-run,
1164
- // which made ffi slower than napi). 2×+128 with a 256-byte floor covers
1165
- // typical claims in one pass; growExact handles huge claims with one
1166
- // exact retry (no re-run loop).
1167
- return growExact(
1168
- (out) =>
1169
- Number(
1170
- jwtSignBytes(
1171
- claimsJson,
1172
- claimsJson.length,
1173
- secret,
1174
- secret.length,
1175
- ttl,
1176
- now,
1177
- out,
1178
- out.length,
1179
- ),
1180
- ),
1181
- Math.min(
1182
- Math.max(
1183
- claimsJson.length * JWT_INITIAL_MULTIPLIER + JWT_INITIAL_EXTRA,
1184
- JWT_INITIAL_FLOOR,
1185
- ),
1186
- 1024 * 1024,
1437
+ // Compact HS256 token returned as a cstring (engine-cloned) — the Rust
1438
+ // side builds the whole token and the JS pays zero decode. ttl<=0 = no
1439
+ // iat/exp (napi Option<i64> sentinel). i64 args must be BigInt.
1440
+ return cstr(
1441
+ jwtSignBytes(
1442
+ claimsJson,
1443
+ lenOrView(claimsJson),
1444
+ secret,
1445
+ lenOrView(secret),
1446
+ BigInt(ttl),
1447
+ BigInt(now),
1448
+ ),
1449
+ 'jwt sign: invalid claims JSON',
1450
+ )
1451
+ },
1452
+ jwtSignBytesInto(claimsJson, secret, ttl, now, output) {
1453
+ // Native pooled `_into`: writes the compact token directly into the
1454
+ // caller buffer (no cstring round-trip). Needed-size convention: a write
1455
+ // larger than `output.length` reports the exact required size → throw;
1456
+ // 0 = invalid claims JSON.
1457
+ const w = Number(
1458
+ jwtSignBytesInto(
1459
+ claimsJson,
1460
+ lenOrView(claimsJson),
1461
+ secret,
1462
+ lenOrView(secret),
1463
+ BigInt(ttl),
1464
+ BigInt(now),
1465
+ output,
1466
+ lenOrView(output),
1187
1467
  ),
1188
- 1024 * 1024,
1189
- 'jwt sign: output buffer too small',
1190
1468
  )
1469
+ if (w === 0) {
1470
+ throw new Error('jwt sign: invalid claims JSON')
1471
+ }
1472
+ if (w > output.length) {
1473
+ throw new Error('jwt sign: output buffer too small')
1474
+ }
1475
+ return w
1191
1476
  },
1192
1477
  wsFrameDecodePacked(data) {
1193
1478
  // Max packed output = 6-byte header + payload.
1194
1479
  const out = new Uint8Array(data.length + 6)
1195
- const w = Number(wsFrameDecodePacked(data, data.length, out, out.length))
1480
+ const w = Number(wsFrameDecodePacked(data, lenOrView(data), out, lenOrView(out)))
1196
1481
  return w === 0 ? null : out.subarray(0, w)
1197
1482
  },
1483
+ wsFrameDecodePackedInto(data, output) {
1484
+ // Pooled sibling: the caller provides the output buffer, sized to at
1485
+ // least `data.length + 6` (6-byte header + payload — the Rust core
1486
+ // returns 0 for BOTH too-small and malformed, so the caller must size
1487
+ // to the max; the allocating path always does). 0 = malformed → null,
1488
+ // mirroring the allocating path's return contract.
1489
+ const w = Number(wsFrameDecodePacked(data, lenOrView(data), output, lenOrView(output)))
1490
+ return w === 0 ? null : w
1491
+ },
1198
1492
  multipartParsePacked(body, boundary) {
1199
1493
  return growExact(
1200
1494
  (out) =>
1201
1495
  Number(
1202
- multipartParsePacked(body, body.length, boundary, boundary.length, out, out.length),
1496
+ multipartParsePacked(
1497
+ body,
1498
+ lenOrView(body),
1499
+ boundary,
1500
+ lenOrView(boundary),
1501
+ out,
1502
+ lenOrView(out),
1503
+ ),
1203
1504
  ),
1204
1505
  Math.min(body.length + boundary.length + 64, 64 * 1024),
1205
1506
  128 * 1024 * 1024,
1206
1507
  'multipart parse: output buffer too small',
1207
1508
  )
1208
1509
  },
1510
+ multipartParsePackedInto(body, boundary, output) {
1511
+ // Pooled sibling: the caller provides the output buffer. Needed-size
1512
+ // convention (same as queryParsePackedInto): w > output.length = exact
1513
+ // required size → throw; w === 0 = real error (malformed).
1514
+ const w = Number(
1515
+ multipartParsePacked(
1516
+ body,
1517
+ lenOrView(body),
1518
+ boundary,
1519
+ lenOrView(boundary),
1520
+ output,
1521
+ lenOrView(output),
1522
+ ),
1523
+ )
1524
+ if (w === 0 || w > output.length) {
1525
+ throw new Error('multipart parse: output buffer too small or malformed body')
1526
+ }
1527
+ return w
1528
+ },
1209
1529
  formParsePackedInto(input, output) {
1210
- const w = Number(formParsePacked(input, input.length, output, output.length))
1211
- if (w === 0 && input.length !== 0) {
1530
+ const w = Number(formParsePacked(input, lenOrView(input), output, lenOrView(output)))
1531
+ // Needed-size convention (form aliases the query core — same convention).
1532
+ if (w === 0 || w > output.length) {
1212
1533
  throw new Error('form parse: output buffer too small')
1213
1534
  }
1214
1535
  return w
1215
1536
  },
1537
+
1538
+ // ── Wired WIP surface (JWT verify, task group) ──
1539
+ jsonParsePacked(input) {
1540
+ // Packed token stream (deduped string table + typed value tree) — the
1541
+ // scalar wrapper assembles the JS value from the tokens with NO second
1542
+ // JSON text parse. `0` = invalid JSON (real error → growExact throws);
1543
+ // `w > out.length` = exact required size (one exact retry).
1544
+ return growExact(
1545
+ (out) => Number(jsonParsePackedSym(input, lenOrView(input), out, lenOrView(out))),
1546
+ Math.min(input.length + (input.length >> 1), 16 * 1024 * 1024),
1547
+ Math.max(1024 * 1024, input.length * 16),
1548
+ 'json parse packed: invalid JSON or output buffer too small',
1549
+ )
1550
+ },
1551
+ parseMediaType(input) {
1552
+ // Packed verdict: [u32 mediaTypeLen][mediaType][u32 charsetLen
1553
+ // (0xFFFFFFFF = none)][charset][u32 boundaryLen][boundary]
1554
+ // [u32 paramCount]{[u32 keyLen][key][u32 valLen][val]}. 0 = invalid media
1555
+ // type (real error → growExact throws); w > output.length = exact needed
1556
+ // size (one exact retry).
1557
+ return growExact(
1558
+ (out) => Number(parseMediaTypeSym(input, lenOrView(input), out, lenOrView(out))),
1559
+ Math.min(input.length + 64, 64 * 1024),
1560
+ 1024 * 1024,
1561
+ 'media type parse: invalid media type or output buffer too small',
1562
+ )
1563
+ },
1564
+ parseHttpDate(input) {
1565
+ // Packed [u8 ok][i64 secs LE] (9 B) / 1 B (ok=0). A too-small buffer is
1566
+ // never a 0 here — the Rust side reports the exact size (9/1) — so a 9-byte
1567
+ // buffer always succeeds. Reused scratch + cached DataView (no per-call
1568
+ // allocs).
1569
+ const out = dateScratch
1570
+ const w = Number(parseHttpDateSym(input, lenOrView(input), out, lenOrView(out)))
1571
+ if (w === 0) {
1572
+ throw new Error('http date parse: output buffer too small')
1573
+ }
1574
+ if (out[0] === 0) return null
1575
+ return dateScratchView.getBigInt64(1, true)
1576
+ },
1577
+ parseAcceptEncoding(input) {
1578
+ // Packed: [u32 count]{[u32 encLen][enc][f32 q][u32 order]} (empty header →
1579
+ // count 0, 4 bytes). 0 = too small (real error → growExact throws).
1580
+ return growExact(
1581
+ (out) => Number(parseAcceptEncodingSym(input, lenOrView(input), out, lenOrView(out))),
1582
+ Math.min(input.length + 64, 64 * 1024),
1583
+ 1024 * 1024,
1584
+ 'accept encoding parse: output buffer too small',
1585
+ )
1586
+ },
1587
+ urlEncodeQuery(input) {
1588
+ // Packed pairs `[u32 count]{[u32 keyLen][key][u32 valLen][val]}` (the JS
1589
+ // `packPairs` layout) → percent-encoded query TEXT as a cstring, keys
1590
+ // SORTED (matches the napi BTreeMap ordering). `null` = malformed packed
1591
+ // input / non-UTF-8 (napi parity: throws).
1592
+ return urlEncodeQuerySym(input, lenOrView(input))
1593
+ },
1594
+ urlResolve(base, reference) {
1595
+ // RFC 3986 resolution → cstring; `null` = non-UTF-8 input (napi parity).
1596
+ return urlResolveSym(base, lenOrView(base), reference, lenOrView(reference))
1597
+ },
1598
+ urlBuilderResolve(inner, reference) {
1599
+ // Opaque-handle resolve against a `UrlBuilder`'s PRECOMPILED base. 0 =
1600
+ // null handle / non-UTF-8 reference (real error → growExact throws);
1601
+ // w > output.length = exact needed size (one exact retry).
1602
+ return growExact(
1603
+ (out) =>
1604
+ Number(urlBuilderResolveRaw(inner, reference, lenOrView(reference), out, lenOrView(out))),
1605
+ Math.min(reference.length * 2 + 128, 64 * 1024),
1606
+ 1024 * 1024,
1607
+ 'url builder resolve: invalid reference or output buffer too small',
1608
+ )
1609
+ },
1610
+ mimeFromExtension(ext) {
1611
+ // Extension → MIME → cstring (cstring ARG: the engine transcodes the JS
1612
+ // extension in-engine); unknown → `application/octet-stream` (never null).
1613
+ return mimeFromExtensionSym(ext)
1614
+ },
1615
+ jwtVerify(token, secret, nowSeconds) {
1616
+ // Verify an HS256 JWT → claims as a JSON cstring; `null` = invalid
1617
+ // signature / expired / malformed (napi Option parity). `now` is an i64
1618
+ // C arg → must be passed as BigInt.
1619
+ return jwtVerifySym(token, lenOrView(token), secret, lenOrView(secret), BigInt(nowSeconds))
1620
+ },
1621
+
1622
+ // ── Napi-only hot ops, now FFI (kills the napi crossing) ──────
1623
+ httpDateInto(secs, output) {
1624
+ // 29-byte HTTP-date into the caller buffer. 0 = too-small buffer or
1625
+ // out-of-range year (napi httpDateInto throws on both).
1626
+ const w = Number(httpDateIntoRaw(secs, output, lenOrView(output)))
1627
+ if (w === 0) {
1628
+ throw new Error('http date: output buffer too small or year out of range')
1629
+ }
1630
+ return w
1631
+ },
1632
+ httpDate(secs) {
1633
+ // Allocating sibling — 32-byte buffer always fits the 29-byte date, so
1634
+ // the only 0 case is an out-of-range year (napi falls back to the
1635
+ // allocating format! there — mirror with Date.toUTCString). Pooled
1636
+ // scratch (decoded synchronously — safe).
1637
+ const out = scratchFor(32)
1638
+ const w = Number(httpDateIntoRaw(secs ?? 0, out, lenOrView(out)))
1639
+ if (w !== 0) return decodeUtf8(out.subarray(0, w))
1640
+ return new Date((secs ?? 0) * 1000).toUTCString()
1641
+ },
1642
+ sseEncodeEvent(event, data, id, retry) {
1643
+ // One SSE event → bytes. event/id are encoded to UTF-8 only when
1644
+ // non-null (flag bits 1/2/4 = present) so a present-but-empty string is
1645
+ // distinct from absent (napi Option parity). growExact with the needed-size
1646
+ // convention; `data.length + 64` covers the common single-line case.
1647
+ const ev = event === null ? EMPTY_VIEW : encodeUtf8(event)
1648
+ const idv = id === null ? EMPTY_VIEW : encodeUtf8(id)
1649
+ const flags = (event === null ? 0 : 1) | (id === null ? 0 : 2) | (retry === null ? 0 : 4)
1650
+ return growExact(
1651
+ (out) =>
1652
+ Number(
1653
+ sseEncodeIntoRaw(
1654
+ ev,
1655
+ lenOrView(ev),
1656
+ data,
1657
+ lenOrView(data),
1658
+ idv,
1659
+ lenOrView(idv),
1660
+ flags,
1661
+ retry ?? 0,
1662
+ out,
1663
+ lenOrView(out),
1664
+ ),
1665
+ ),
1666
+ data.length + 64,
1667
+ 1 << 20,
1668
+ 'sse encode: output buffer too small',
1669
+ )
1670
+ },
1671
+ sseEncodeEventInto(event, data, id, retry, output) {
1672
+ // Pooled sibling — caller-owned output buffer (sized ≥ `data.length + 64`
1673
+ // for the common single-line case). Needed-size convention: w > output.length
1674
+ // = exact required size → throw; w === 0 = real error (invalid UTF-8).
1675
+ const ev = event === null ? EMPTY_VIEW : encodeUtf8(event)
1676
+ const idv = id === null ? EMPTY_VIEW : encodeUtf8(id)
1677
+ const flags = (event === null ? 0 : 1) | (id === null ? 0 : 2) | (retry === null ? 0 : 4)
1678
+ const w = Number(
1679
+ sseEncodeIntoRaw(
1680
+ ev,
1681
+ lenOrView(ev),
1682
+ data,
1683
+ lenOrView(data),
1684
+ idv,
1685
+ lenOrView(idv),
1686
+ flags,
1687
+ retry ?? 0,
1688
+ output,
1689
+ lenOrView(output),
1690
+ ),
1691
+ )
1692
+ if (w === 0) {
1693
+ throw new Error('sse encode: invalid UTF-8 in event/id')
1694
+ }
1695
+ if (w > output.length) {
1696
+ throw new Error('sse encode: output buffer too small')
1697
+ }
1698
+ return w
1699
+ },
1700
+
1216
1701
  ingressHandlePacked(inner, input, body, output) {
1217
1702
  const w = Number(
1218
1703
  ingressHandlePacked(
1219
1704
  inner,
1220
1705
  input,
1221
- input.length,
1706
+ lenOrView(input),
1222
1707
  body ?? EMPTY_VIEW,
1223
- body?.length ?? 0,
1708
+ // Under `buffer_length` the length slot must be a view (the engine
1709
+ // reads byteLength off it) — `EMPTY_VIEW` has byteLength 0, matching
1710
+ // a null body; under `(ptr,usize)` it's the explicit length 0.
1711
+ body ? lenOrView(body) : lenOrView(EMPTY_VIEW),
1224
1712
  output,
1225
- output.length,
1713
+ lenOrView(output),
1226
1714
  ),
1227
1715
  )
1228
1716
  if (w === 0) {
@@ -1230,312 +1718,58 @@ function build(
1230
1718
  }
1231
1719
  return w
1232
1720
  },
1721
+ ingressHandleComponents(inner, methodKind, url, ip, rid, headers, body, output) {
1722
+ // `url`/`ip` are passed as JS strings to `cstring` args — the engine
1723
+ // transcodes them to call-scoped NUL-terminated UTF-8 buffers in-engine
1724
+ // (no JS-side `Buffer.write` encode, no frame assembly for URL/IP).
1725
+ const w = Number(
1726
+ ingressHandleComponentsSym(
1727
+ inner,
1728
+ methodKind,
1729
+ url,
1730
+ ip,
1731
+ rid,
1732
+ lenOrView(rid),
1733
+ headers,
1734
+ lenOrView(headers),
1735
+ body ?? EMPTY_VIEW,
1736
+ body ? lenOrView(body) : lenOrView(EMPTY_VIEW),
1737
+ output,
1738
+ lenOrView(output),
1739
+ ),
1740
+ )
1741
+ if (w === 0) {
1742
+ throw new Error('ingress components: output buffer too small or pipeline error')
1743
+ }
1744
+ return w
1745
+ },
1233
1746
  ingressLayout(out) {
1234
- const w = Number(ingressLayoutSym(out, out.length))
1747
+ const w = Number(ingressLayoutSym(out, lenOrView(out)))
1235
1748
  if (w !== out.length) {
1236
1749
  throw new Error('ingress layout: output buffer too small')
1237
1750
  }
1238
1751
  return w
1239
1752
  },
1240
- }
1241
- }
1242
-
1243
- /** Verify every bound function against known-good results; false disables ffi. */
1244
- function selfTest(b: BunFFI): boolean {
1245
- const dec = new TextDecoder()
1246
- const enc = encoder
1247
-
1248
- if (b.crc32(enc.encode('123456789')) !== 0xcbf4_3926) {
1249
- return false
1250
- }
1251
- if (b.fnv1a64(enc.encode('foobar')) !== 0x8594_4171_f739_67e8n) {
1252
- return false
1253
- }
1254
- // XXH3-64 of empty input = 0x2d06800538d394c2 (standard reference vector).
1255
- if (b.xxh3(new Uint8Array(0)) !== 0x2d06800538d394c2n) {
1256
- return false
1257
- }
1258
- if (b.jsonValid(SELFTEST_JSON) !== true || b.jsonValid(enc.encode('{not json')) !== false) {
1259
- return false
1260
- }
1261
- const hexOut = new Uint8Array(SELFTEST_HEX.length * 2)
1262
- if (b.hexEncodeInto(SELFTEST_HEX, hexOut) !== 10 || dec.decode(hexOut) !== '68656c6c6f') {
1263
- return false
1264
- }
1265
- const urlInput = enc.encode('a b/c')
1266
- const urlOut = new Uint8Array(9)
1267
- if (b.urlEncodeInto(urlInput, urlOut) !== 9 || dec.decode(urlOut) !== 'a%20b%2Fc') {
1268
- return false
1269
- }
1270
-
1271
- // Ingress layout blob (38 × u32 LE). The pinned values catch a reordered
1272
- // `#[repr(C)] IngressLayout` (drift → self-test fails → napi fallback); the
1273
- // Rust unit test `ingress_layout_c_abi_matches_output_source` pins every
1274
- // field against output.rs. Slot order mirrors the struct field order.
1275
- const layoutBuf = new Uint8Array(38 * 4)
1276
- b.ingressLayout(layoutBuf)
1277
- const layoutView = new DataView(layoutBuf.buffer, layoutBuf.byteOffset, layoutBuf.byteLength)
1278
- if (
1279
- layoutView.getUint32(0, true) !== 0 || // OUT_VERDICT
1280
- layoutView.getUint32(2 * 4, true) !== 2 || // OUT_STATUS
1281
- layoutView.getUint32(12 * 4, true) !== 48 || // OUT_DATA_START
1282
- layoutView.getUint32(13 * 4, true) !== 1 || // FLAG_HAS_COOKIES
1283
- layoutView.getUint32(28 * 4, true) !== 32 || // HV_COUNT
1284
- layoutView.getUint32(37 * 4, true) !== 8 // ERR_INTERNAL
1285
- ) {
1286
- return false
1287
- }
1288
-
1289
- // Ingress pipeline C-ABI: with a null (0) inner handle the Rust side returns
1290
- // 0 immediately and the wrapper throws. This exercises the symbol's ABI (arg
1291
- // count/types/return) at bind time — a signature drift would surface here
1292
- // instead of crashing under load. Real frame→output parity is covered by
1293
- // ffi.test.ts against a live napi instance.
1294
- try {
1295
- b.ingressHandlePacked(0, enc.encode('/'), null, new Uint8Array(64))
1296
- return false // a null handle must throw, not return
1297
- } catch {
1298
- // expected: null inner handle → 0 → throw
1299
- }
1300
-
1301
- // ── New bindings ───────────────────────────────────────────────────
1302
- const a = enc.encode('a@b.com')
1303
- const uuid = enc.encode('550e8400-e29b-41d4-a716-446655440000')
1304
- if (
1305
- !b.validateEmail(a) ||
1306
- !b.validateUuid(uuid) ||
1307
- !b.validateIpv4(enc.encode('192.168.0.1')) ||
1308
- !b.validateIpv6(enc.encode('2001:db8::1')) ||
1309
- b.validateEmail(enc.encode('not-an-email')) ||
1310
- b.validateUuid(enc.encode('not-a-uuid'))
1311
- ) {
1312
- return false
1313
- }
1314
- if (b.jsonSumIds(enc.encode(`[{"id":1},{"id":2}]`)) !== 3n) {
1315
- return false
1316
- }
1317
- // The packed [u8 ok][i64 sum LE] ABI: a legit zero-sum is ok, invalid input throws.
1318
- if (b.jsonSumIds(enc.encode(`[{"id":0},{"id":0}]`)) !== 0n) {
1319
- return false
1320
- }
1321
- let sumInvalidThrew = false
1322
- try {
1323
- b.jsonSumIds(enc.encode('nope'))
1324
- } catch {
1325
- sumInvalidThrew = true
1326
- }
1327
- if (!sumInvalidThrew) {
1328
- return false
1329
- }
1330
-
1331
- // HMAC RFC 4231 test case 1 (0x0b × 20 key, "Hi There" data).
1332
- const hmacKey = new Uint8Array(20).fill(0x0b)
1333
- const hmacData = enc.encode('Hi There')
1334
- const hmacSig = enc.encode('b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7')
1335
- const hmacHex = b.hmacSha256(hmacKey, hmacData)
1336
- if (dec.decode(hmacHex) !== 'b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7') {
1337
- return false
1338
- }
1339
- if (!b.hmacSha256Verify(hmacKey, hmacData, hmacSig)) {
1340
- return false
1341
- }
1342
-
1343
- // Decoders round-trip.
1344
- const decoded = b.hexDecode(enc.encode('68656c6c6f'))
1345
- if (
1346
- dec.decode(decoded) !== 'hello' ||
1347
- dec.decode(b.urlDecode(enc.encode('a%20b%2Fc'))) !== 'a b/c'
1348
- ) {
1349
- return false
1350
- }
1351
-
1352
- // WebSocket accept key (RFC 6455 sample).
1353
- if (
1354
- dec.decode(b.wsAcceptKey(enc.encode('dGhlIHNhbXBsZSBub25jZQ=='))) !==
1355
- 's3pPLMBiTxaQ9kYGzzhZRbK+xOo='
1356
- ) {
1357
- return false
1358
- }
1359
-
1360
- // ETag: strong = 10 bytes, weak = 12 bytes.
1361
- if (b.etag(SELFTEST_HEX).length !== 10 || b.etag(SELFTEST_HEX, true).length !== 12) {
1362
- return false
1363
- }
1364
-
1365
- // base64.
1366
- if (dec.decode(b.base64Encode(SELFTEST_HEX)) !== 'aGVsbG8=') {
1367
- return false
1368
- }
1369
- if (dec.decode(b.base64Decode(enc.encode('aGVsbG8='))) !== 'hello') {
1370
- return false
1371
- }
1372
-
1373
- // Signed cookie round-trip.
1374
- const secret = enc.encode('s3cr3t-secret')
1375
- const signed = b.signCookie(SELFTEST_HEX, secret)
1376
- const verified = b.verifyCookie(signed, secret)
1377
- if (verified === null || dec.decode(verified) !== 'hello') {
1378
- return false
1379
- }
1380
- if (b.verifyCookie(enc.encode('tampered.0000'), secret) !== null) {
1381
- return false
1382
- }
1383
-
1384
- // CSRF token round-trip (issued token verifies against the same secret).
1385
- const csrfTokenBytes = b.csrfToken(secret)
1386
- if (csrfTokenBytes.length !== 129 || !b.csrfVerify(csrfTokenBytes, secret)) {
1387
- return false
1388
- }
1389
-
1390
- // Argon2id round-trip at minimum cost (fast) — full defaults would take ~50ms.
1391
- const pw = enc.encode('correct horse battery staple')
1392
- const salt = enc.encode('salty-salt-16b')
1393
- const phc = b.passwordHash(pw, salt, 8, 1, 1, 16)
1394
- if (phc.length === 0 || !b.passwordVerify(pw, phc)) {
1395
- return false
1396
- }
1397
-
1398
- // bcrypt round-trip at minimum cost (fast).
1399
- const bcryptPhc = b.passwordHashBcrypt(pw, 4)
1400
- if (bcryptPhc.length === 0 || !b.passwordVerifyBcrypt(pw, bcryptPhc)) {
1401
- return false
1402
- }
1403
-
1404
- // PBKDF2-HMAC-SHA256: password="password", salt="salt", c=1, dkLen=32.
1405
- // The C ABI writes the RAW derived key; hex-encode before comparing.
1406
- const dk = b.pbkdf2Sha256(enc.encode('password'), enc.encode('salt'), 1, 32)
1407
- if (
1408
- dec.decode(b.hexEncode(dk)) !==
1409
- '120fb6cffcf8b32c43e7225256c4f837a86548c92ccc35480805987cb70be17b'
1410
- ) {
1411
- return false
1412
- }
1413
-
1414
- // AEAD AES-256-GCM round-trip (key 32B, nonce 12B).
1415
- const aeadKey = new Uint8Array(32).fill(0x42)
1416
- const nonce = new Uint8Array(12).fill(0x07)
1417
- const ct = b.aeadEncrypt(aeadKey, nonce, SELFTEST_HEX, 0)
1418
- const pt = b.aeadDecrypt(aeadKey, nonce, ct, 0)
1419
- if (pt === null || dec.decode(pt) !== 'hello') {
1420
- return false
1421
- }
1422
-
1423
- // WebSocket frame: text frame, FIN, no mask → first byte 0x81.
1424
- const frame = b.wsFrameEncode(1, SELFTEST_HEX, false, true)
1425
- if (frame.length === 0 || frame[0] !== 0x81) {
1426
- return false
1427
- }
1428
1753
 
1429
- // JSON patch: add a key.
1430
- const patched = b.jsonPatch(
1431
- enc.encode(`{"a":"b"}`),
1432
- enc.encode(`[{"op":"add","path":"/c","value":"d"}]`),
1433
- )
1434
- if (!dec.decode(patched).includes(`"c":"d"`)) {
1435
- return false
1436
- }
1437
-
1438
- // gzip / brotli round-trips.
1439
- const gz = b.gzipCompress(SELFTEST_HEX)
1440
- if (dec.decode(b.gzipDecompress(gz)) !== 'hello') {
1441
- return false
1442
- }
1443
- const br = b.brotliCompress(SELFTEST_HEX)
1444
- if (dec.decode(b.brotliDecompress(br)) !== 'hello') {
1445
- return false
1446
- }
1447
-
1448
- // Needed-size convention: invalid compressed input throws IMMEDIATELY (the C
1449
- // ABI returns 0 = real error, so the JS wrapper does NOT grow-retry re-runs
1450
- // or allocate up to the 64 MiB decompression cap per bad input).
1451
- let decompressThrew = false
1452
- try {
1453
- b.gzipDecompress(enc.encode('not-a-gzip-stream'))
1454
- } catch {
1455
- decompressThrew = true
1456
- }
1457
- if (!decompressThrew) {
1458
- return false
1459
- }
1460
- decompressThrew = false
1461
- try {
1462
- b.brotliDecompress(enc.encode('not-brotli-stream'))
1463
- } catch {
1464
- decompressThrew = true
1465
- }
1466
- if (!decompressThrew) {
1467
- return false
1468
- }
1469
-
1470
- // Packed parsers (non-empty output). Packed output is LARGER than input (each
1471
- // component gets a u32 length prefix), so size with the Rust allocator's
1472
- // conservative upper bound (`input.len() * 9 + 16` in query_parser.rs).
1473
- const req = enc.encode('GET /a?b=1 HTTP/1.1\r\nHost: example.com\r\n\r\n')
1474
- const reqOut = new Uint8Array(req.length * 9 + 16)
1475
- if (b.httpParseRequestPackedInto(req, reqOut) === 0) {
1476
- return false
1477
- }
1478
- const qIn = enc.encode('a=1&b=2')
1479
- const qOut = new Uint8Array(qIn.length * 9 + 16)
1480
- if (b.queryParsePackedInto(qIn, qOut) === 0) {
1481
- return false
1482
- }
1483
- const cIn = enc.encode('a=1; b=2')
1484
- const cOut = new Uint8Array(cIn.length * 9 + 16)
1485
- if (b.cookieParsePackedInto(cIn, cOut) === 0) {
1486
- return false
1487
- }
1488
-
1489
- // ── Excluded-surface additions ────────────────────────────────────
1490
- // form parse shares the query core → 2 pairs.
1491
- const fIn = enc.encode('a=1&b=2')
1492
- const fOut = new Uint8Array(fIn.length * 9 + 16)
1493
- if (b.formParsePackedInto(fIn, fOut) === 0 || fOut[0] !== 2) {
1494
- return false
1495
- }
1496
-
1497
- // multipart parse → 1 part named "field".
1498
- const boundary = enc.encode('----boundary')
1499
- // Wire format is `--{boundary}` — boundary is `----boundary`, so the body
1500
- // must open with `------boundary`.
1501
- const mBody = enc.encode(
1502
- '------boundary\r\nContent-Disposition: form-data; name="field"\r\n\r\nvalue\r\n------boundary--',
1503
- )
1504
- const mOut = b.multipartParsePacked(mBody, boundary)
1505
- if (mOut[0] !== 1) {
1506
- return false
1507
- }
1508
- // Packed layout: [u32 count][u32 name_len][name]... → name_len at offset 4.
1509
- const mNameLen = (mOut[4] ?? 0) | ((mOut[5] ?? 0) << 8) | ((mOut[6] ?? 0) << 16) | ((mOut[7] ?? 0) << 24)
1510
- if (dec.decode(mOut.subarray(8, 8 + mNameLen)) !== 'field') {
1511
- return false
1512
- }
1513
-
1514
- // WS frame decode: encode("hello") → decode → fin=1, opcode=1, payload="hello".
1515
- const wf = b.wsFrameEncode(1, SELFTEST_HEX, true, true)
1516
- const wd = b.wsFrameDecodePacked(wf)
1517
- if (wd === null || wd[0] !== 1 || wd[1] !== 1 || dec.decode(wd.subarray(6)) !== 'hello') {
1518
- return false
1519
- }
1520
- if (b.wsFrameDecodePacked(enc.encode('\x80')) !== null) {
1521
- return false
1522
- }
1523
-
1524
- // JWT sign with ttl=0 (deterministic — no iat/exp), then verify the
1525
- // signature with the FFI HMAC to prove the binding is real.
1526
- const jwtSecret = enc.encode('my-secret')
1527
- const jwt = b.jwtSignBytes(enc.encode('{"sub":"user-1"}'), jwtSecret, 0, 0)
1528
- const jwtStr = dec.decode(jwt)
1529
- const segs = jwtStr.split('.')
1530
- if (segs.length !== 3 || segs[0] === '' || segs[1] === '' || segs[2] === '') {
1531
- return false
1532
- }
1533
- const signingInput = enc.encode(`${segs[0]}.${segs[1]}`)
1534
- const sigHex = dec.decode(b.hmacSha256(jwtSecret, signingInput))
1535
- const sigBytes = b.base64Decode(enc.encode(segs[2]), true, false)
1536
- if (sigBytes === null || dec.decode(b.hexEncode(sigBytes)) !== sigHex) {
1537
- return false
1754
+ routeCompile(descriptor) {
1755
+ const handle = Number(routeCompileSym(descriptor, lenOrView(descriptor)))
1756
+ if (handle === 0) {
1757
+ throw new Error('route compile: invalid route descriptor')
1758
+ }
1759
+ return handle
1760
+ },
1761
+ routeRun(handle, frame, output) {
1762
+ // Needed-size convention: `0` = real error (malformed frame / panic); a
1763
+ // write larger than `output.length` is the EXACT required size (caller
1764
+ // allocates once and retries) — only a `0` write throws here.
1765
+ const w = Number(routeRunSym(handle, frame, lenOrView(frame), output, lenOrView(output)))
1766
+ if (w === 0) {
1767
+ throw new Error('route run: malformed frame or pipeline error')
1768
+ }
1769
+ return w
1770
+ },
1771
+ routeDestroy(handle) {
1772
+ routeDestroySym(handle)
1773
+ },
1538
1774
  }
1539
-
1540
- return true
1541
1775
  }