castrum 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (176) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +322 -59
  3. package/castrum.linux-x64-gnu.node +0 -0
  4. package/dist/index.d.ts +3161 -0
  5. package/dist/index.js +7306 -0
  6. package/docs/ARCHITECTURE.md +415 -0
  7. package/docs/BENCHMARKS.md +307 -0
  8. package/docs/ENVIRONMENT.md +79 -0
  9. package/docs/GETTING_STARTED.md +209 -0
  10. package/docs/INGRESS.md +745 -0
  11. package/docs/REPO_MAP.md +259 -0
  12. package/docs/adr/0001-two-wire-formats.md +39 -0
  13. package/docs/adr/0002-zero-dom-fast-schema.md +38 -0
  14. package/docs/adr/0003-bun-builtins-delegation.md +42 -0
  15. package/docs/bun-builtins-decision-matrix.md +108 -0
  16. package/index.ts +51 -8
  17. package/package.json +73 -20
  18. package/src/data/json-rows.ts +13 -13
  19. package/src/ingress/body.ts +149 -0
  20. package/src/ingress/constants.ts +220 -48
  21. package/src/ingress/context.ts +136 -0
  22. package/src/ingress/decode/baked-result.ts +121 -0
  23. package/src/ingress/decode/fast-result.ts +160 -0
  24. package/src/ingress/decode/packed-sections.ts +56 -0
  25. package/src/ingress/decode/result-base.ts +172 -0
  26. package/src/ingress/errors.ts +72 -0
  27. package/src/ingress/fast.ts +152 -1021
  28. package/src/ingress/handlers.ts +563 -1001
  29. package/src/ingress/headers/baked-templates.ts +102 -0
  30. package/src/ingress/headers/cors.ts +44 -0
  31. package/src/ingress/headers/fast-templates.ts +238 -0
  32. package/src/ingress/headers/hsts.ts +83 -0
  33. package/src/ingress/health.ts +67 -0
  34. package/src/ingress/index.ts +130 -405
  35. package/src/ingress/metrics.ts +132 -0
  36. package/src/ingress/options.ts +197 -0
  37. package/src/ingress/packing/gather-raw-headers.ts +75 -0
  38. package/src/ingress/packing/header-packing.ts +75 -0
  39. package/src/ingress/packing/input-packer.ts +150 -0
  40. package/src/ingress/packing/scratch.ts +50 -0
  41. package/src/ingress/packing/select-headers.ts +96 -0
  42. package/src/ingress/response/error-bodies.ts +75 -0
  43. package/src/ingress/response/terminal.ts +59 -0
  44. package/src/ingress/routes/common.ts +38 -0
  45. package/src/ingress/routes/delete.ts +14 -0
  46. package/src/ingress/routes/echo.ts +107 -0
  47. package/src/ingress/routes/fallback.ts +23 -0
  48. package/src/ingress/routes/head.ts +37 -0
  49. package/src/ingress/routes/index.ts +10 -0
  50. package/src/ingress/routes/json-write.ts +153 -0
  51. package/src/ingress/routes/options.ts +36 -0
  52. package/src/ingress/routes/read.ts +45 -0
  53. package/src/ingress/server-node.ts +376 -0
  54. package/src/ingress/server.ts +443 -0
  55. package/src/ingress/shared.ts +132 -0
  56. package/src/ingress/status.ts +97 -0
  57. package/src/ingress/types.ts +157 -0
  58. package/src/integration/batch.ts +58 -0
  59. package/src/integration/index.ts +29 -0
  60. package/src/integration/pipeline.ts +217 -0
  61. package/src/integration/streaming.ts +79 -0
  62. package/src/integration/websocket.ts +92 -0
  63. package/src/loader/batch.ts +133 -0
  64. package/src/loader/cost.ts +104 -0
  65. package/src/loader/index.ts +930 -0
  66. package/src/loader/ops.ts +657 -0
  67. package/src/native/ffi.ts +1541 -0
  68. package/src/native/index.ts +8 -207
  69. package/src/native/loader.ts +194 -0
  70. package/src/native/types.ts +598 -0
  71. package/src/rust-ffi/addon.ts +10 -0
  72. package/src/rust-ffi/batch.ts +431 -0
  73. package/src/rust-ffi/client.ts +88 -0
  74. package/src/rust-ffi/context.ts +164 -0
  75. package/src/rust-ffi/index.ts +40 -541
  76. package/src/rust-ffi/options.ts +113 -0
  77. package/src/rust-ffi/packed.ts +253 -0
  78. package/src/rust-ffi/proven.ts +42 -0
  79. package/src/rust-ffi/scalar/crypto.ts +265 -0
  80. package/src/rust-ffi/scalar/factories.ts +233 -0
  81. package/src/rust-ffi/scalar/hashing.ts +72 -0
  82. package/src/rust-ffi/scalar/http.ts +206 -0
  83. package/src/rust-ffi/scalar/index.ts +31 -0
  84. package/src/rust-ffi/scalar/interface.ts +450 -0
  85. package/src/rust-ffi/scalar/json.ts +45 -0
  86. package/src/rust-ffi/scalar/payload.ts +124 -0
  87. package/src/rust-ffi/text.ts +95 -0
  88. package/src/selection.json +194 -0
  89. package/src/selection.ts +77 -0
  90. package/src/shared/adaptive.ts +68 -0
  91. package/src/shared/bench-classify.ts +209 -0
  92. package/src/shared/buffer-pool.ts +209 -0
  93. package/src/shared/bytes.ts +35 -5
  94. package/src/shared/env.ts +20 -0
  95. package/src/shared/log.ts +74 -0
  96. package/src/shared/metrics.ts +247 -0
  97. package/src/shared/packed.ts +350 -154
  98. package/src/shared/proven.ts +596 -0
  99. package/src/shared/request-id.ts +67 -0
  100. package/src/shared/response.ts +104 -0
  101. package/src/shared/runtime.ts +34 -0
  102. package/src/shared/trace.ts +81 -0
  103. package/src/shared/uuid.ts +31 -0
  104. package/Cargo.toml +0 -73
  105. package/bench.ts +0 -3
  106. package/rust/batch.rs +0 -195
  107. package/rust/cookie_parser.rs +0 -70
  108. package/rust/cors.rs +0 -263
  109. package/rust/hashing.rs +0 -164
  110. package/rust/headers.rs +0 -234
  111. package/rust/hmac_sha256.rs +0 -113
  112. package/rust/http_parser.rs +0 -132
  113. package/rust/ingress.rs +0 -616
  114. package/rust/ingress_constants.rs +0 -89
  115. package/rust/ip_trust.rs +0 -185
  116. package/rust/json_ops.rs +0 -66
  117. package/rust/json_patch_ops.rs +0 -25
  118. package/rust/json_schema.rs +0 -151
  119. package/rust/json_ser.rs +0 -429
  120. package/rust/lib.rs +0 -48
  121. package/rust/method.rs +0 -141
  122. package/rust/mime_lookup.rs +0 -76
  123. package/rust/output.rs +0 -238
  124. package/rust/proxy.rs +0 -90
  125. package/rust/query_parser.rs +0 -141
  126. package/rust/random_token.rs +0 -52
  127. package/rust/rate_limit.rs +0 -203
  128. package/rust/terminal.rs +0 -95
  129. package/rust/unit_tests.rs +0 -843
  130. package/rust/url_codec.rs +0 -133
  131. package/rust/util.rs +0 -537
  132. package/rust/validation.rs +0 -107
  133. package/rust/websocket.rs +0 -23
  134. package/src/baseline/index.ts +0 -12
  135. package/src/baseline/tasks/cookie.ts +0 -22
  136. package/src/baseline/tasks/hashing.ts +0 -18
  137. package/src/baseline/tasks/hmac.ts +0 -38
  138. package/src/baseline/tasks/http.ts +0 -62
  139. package/src/baseline/tasks/json-patch.ts +0 -12
  140. package/src/baseline/tasks/json.ts +0 -50
  141. package/src/baseline/tasks/mime.ts +0 -5
  142. package/src/baseline/tasks/query.ts +0 -14
  143. package/src/baseline/tasks/token.ts +0 -15
  144. package/src/baseline/tasks/url.ts +0 -11
  145. package/src/baseline/tasks/validation.ts +0 -19
  146. package/src/baseline/tasks/websocket.ts +0 -14
  147. package/src/bench/assert.ts +0 -70
  148. package/src/bench/checks.ts +0 -227
  149. package/src/bench/checksum.ts +0 -27
  150. package/src/bench/comparisons.ts +0 -70
  151. package/src/bench/concurrent-worker.ts +0 -135
  152. package/src/bench/fixtures.ts +0 -233
  153. package/src/bench/index.ts +0 -2
  154. package/src/bench/measure.ts +0 -177
  155. package/src/bench/now.ts +0 -3
  156. package/src/bench/report.ts +0 -79
  157. package/src/bench/run.ts +0 -57
  158. package/src/bench/tasks/complex.ts +0 -232
  159. package/src/bench/tasks/concurrent.ts +0 -213
  160. package/src/bench/tasks/cookie.ts +0 -44
  161. package/src/bench/tasks/hashing.ts +0 -33
  162. package/src/bench/tasks/hmac.ts +0 -36
  163. package/src/bench/tasks/http.ts +0 -50
  164. package/src/bench/tasks/index.ts +0 -52
  165. package/src/bench/tasks/json-patch.ts +0 -21
  166. package/src/bench/tasks/json.ts +0 -33
  167. package/src/bench/tasks/mime.ts +0 -21
  168. package/src/bench/tasks/query.ts +0 -44
  169. package/src/bench/tasks/stress.ts +0 -142
  170. package/src/bench/tasks/token.ts +0 -20
  171. package/src/bench/tasks/url.ts +0 -34
  172. package/src/bench/tasks/validation.ts +0 -57
  173. package/src/bench/tasks/websocket.ts +0 -21
  174. package/src/bench/types.ts +0 -60
  175. package/src/ingress/packed-input.ts +0 -59
  176. package/src/shared/json.ts +0 -18
@@ -0,0 +1,1541 @@
1
+ // src/native/ffi.ts — Bun-only C-ABI fast path via `bun:ffi`.
2
+ //
3
+ // Bun JIT-compiles `bun:ffi` calls down to direct native calls (~10-20ns
4
+ // crossing), versus ~100-350ns for a Node-API call. The same cdylib that Node
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.
8
+ //
9
+ // Safety / correctness strategy:
10
+ // - 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.
22
+ // - Never called under Node (falls back to napi immediately).
23
+ //
24
+ // Buffer ABI: input/out pointers are `(ptr, len)` pairs; the JS wrapper passes
25
+ // `(view, view.length)` and bun:ffi converts the TypedArray to its pointer.
26
+
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
+ import type { FFITypeOrString } from 'bun:ffi'
30
+ import { resolveEnvVar } from '../shared/env'
31
+ import { isBun } from '../shared/runtime'
32
+ // Static import: loader.ts has no side effects (path resolution only — no
33
+ // dlopen), so this adds zero import-time cost while letting `bind()` reuse the
34
+ // exact same resolved `.node` path the napi fallback uses (the shared seam).
35
+ import { getAddonPath } from './loader'
36
+
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)
254
+
255
+ let cached: BunFFI | null | undefined
256
+
257
+ // ── 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
+
267
+ function resolveFfiMode(): FfiMode {
268
+ const raw = resolveEnvVar('CASTRUM_FFI_MODE', ['RUST_FFI_MODE'])
269
+ switch (raw) {
270
+ case 'ffi':
271
+ return 'ffi'
272
+ case 'napi':
273
+ return 'napi'
274
+ default:
275
+ return 'auto'
276
+ }
277
+ }
278
+
279
+ /**
280
+ * Lazily bind the Bun ffi fast path, or return `null` when unavailable
281
+ * (Node, `CASTRUM_FFI_MODE=napi`, missing symbols, or a failed self-test).
282
+ * `undefined` caches "not yet attempted".
283
+ *
284
+ * Under `CASTRUM_FFI_MODE=ffi` a bind/self-test failure THROWS instead of
285
+ * returning null — the caller asked for the primary transport explicitly.
286
+ */
287
+ export function getBunFFI(): BunFFI | null {
288
+ if (cached !== undefined) {
289
+ return cached
290
+ }
291
+ cached = bind()
292
+ return cached
293
+ }
294
+
295
+ /**
296
+ * Probe whether this Bun build accepts the `buffer`/`buffer_length` ABI pair in
297
+ * `dlopen`. Bun's docs list `buffer_length` as engine-native (dlopen-supported),
298
+ * but an earlier canary threw "invalid ABI type" for it. If that regressed (or
299
+ * a future Bun removes it), we silently keep explicit `(ptr, len)` pairs. The
300
+ * full bind-time self-test is the safety net either way.
301
+ */
302
+ function probeBufferLength(dlopen: typeof import('bun:ffi')['dlopen'], path: string): boolean {
303
+ try {
304
+ const { symbols, close } = dlopen(path, {
305
+ // bun-types lacks the `buffer_length` literal — cast it; runtime support
306
+ // is exactly what this probe is verifying.
307
+ castrum_crc32: {
308
+ args: ['buffer', 'buffer_length'] as unknown as readonly FFITypeOrString[],
309
+ returns: 'u32',
310
+ },
311
+ })
312
+ // `buffer_length` is `buffer`'s length twin — pass the SAME view twice
313
+ // (the engine reads ptr + byteLength off that object at call time).
314
+ const view = new Uint8Array([1, 2, 3])
315
+ const out = (symbols as Record<string, (a: unknown, b: unknown) => unknown>).castrum_crc32?.(
316
+ view,
317
+ view,
318
+ )
319
+ close()
320
+ return typeof out === 'number' && out >= 0
321
+ } catch {
322
+ return false
323
+ }
324
+ }
325
+
326
+ function bind(): BunFFI | null {
327
+ const mode = resolveFfiMode()
328
+ if (!isBun()) {
329
+ if (mode === 'ffi') {
330
+ throw new Error(
331
+ 'CASTRUM_FFI_MODE=ffi is invalid here: bun:ffi is a Bun-only transport ' +
332
+ '(this process is not Bun). Use CASTRUM_FFI_MODE=auto (default) so Node ' +
333
+ 'uses the napi fallback.',
334
+ )
335
+ }
336
+ return null
337
+ }
338
+ if (mode === 'napi') {
339
+ return null
340
+ }
341
+ try {
342
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
343
+ const { dlopen } = require('bun:ffi') as typeof import('bun:ffi')
344
+ // Resolve the SAME addon file napi uses (./loader.ts — statically imported
345
+ // above; it only resolves the path, it never dlopens the addon).
346
+ const path = getAddonPath()
347
+
348
+ // Probe `buffer`/`buffer_length` (the engine reads ptr + len off the SAME
349
+ // TypedArray at call time — an atomic snapshot) once; when supported we use
350
+ // it for the input-only hot fns (one JS arg instead of two), else `(ptr,len)`.
351
+ const useBufferLength = probeBufferLength(dlopen, path)
352
+
353
+ // String ABI specs ("ptr"/"usize"/"u32"...): accepted by bun:ffi and typed
354
+ // via `FFITypeOrString` in bun-types. usize == uint64 on the host ABI.
355
+ // Symbol keys must be the exact `#[no_mangle] extern "C"` names in
356
+ // rust/ffi.rs. Every `(ptr,len)` pair is passed as `(view, view.length)`.
357
+ // The 8 input-only single-buffer fns use `inputAbi`; everything else keeps
358
+ // `(ptr,len)` because the output length is the caller's write cap.
359
+ const inputAbi: readonly FFITypeOrString[] = useBufferLength
360
+ ? (['buffer', 'buffer_length'] as unknown as readonly FFITypeOrString[])
361
+ : ['ptr', 'usize']
362
+ const { symbols } = dlopen(path, {
363
+ castrum_crc32: { args: inputAbi, returns: 'u32' },
364
+ castrum_fnv1a64: { args: inputAbi, returns: 'u64' },
365
+ castrum_xxh3: { args: inputAbi, returns: 'u64' },
366
+ 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' },
376
+ castrum_hmac_sha256_verify: {
377
+ args: ['ptr', 'usize', 'ptr', 'usize', 'ptr', 'usize'],
378
+ returns: 'u8',
379
+ },
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' },
386
+ castrum_base64_encode: {
387
+ args: ['ptr', 'usize', 'ptr', 'usize', 'u8', 'u8'],
388
+ returns: 'usize',
389
+ },
390
+ castrum_base64_decode: {
391
+ args: ['ptr', 'usize', 'ptr', 'usize', 'u8', 'u8'],
392
+ returns: 'usize',
393
+ },
394
+ castrum_hmac_sha256: {
395
+ args: ['ptr', 'usize', 'ptr', 'usize', 'ptr', 'usize'],
396
+ returns: 'usize',
397
+ },
398
+ castrum_sign_cookie: {
399
+ args: ['ptr', 'usize', 'ptr', 'usize', 'ptr', 'usize'],
400
+ returns: 'usize',
401
+ },
402
+ castrum_verify_cookie: {
403
+ args: ['ptr', 'usize', 'ptr', 'usize', 'ptr', 'usize'],
404
+ returns: 'usize',
405
+ },
406
+ castrum_csrf_token: { args: ['ptr', 'usize', 'ptr', 'usize'], returns: 'usize' },
407
+ castrum_password_hash: {
408
+ args: ['ptr', 'usize', 'ptr', 'usize', 'u32', 'u32', 'u32', 'u32', 'ptr', 'usize'],
409
+ returns: 'usize',
410
+ },
411
+ castrum_password_hash_bcrypt: {
412
+ args: ['ptr', 'usize', 'u32', 'ptr', 'usize'],
413
+ returns: 'usize',
414
+ },
415
+ castrum_pbkdf2_sha256: {
416
+ args: ['ptr', 'usize', 'ptr', 'usize', 'u32', 'u32', 'ptr', 'usize'],
417
+ returns: 'usize',
418
+ },
419
+ castrum_aead_encrypt: {
420
+ args: ['ptr', 'usize', 'ptr', 'usize', 'ptr', 'usize', 'u8', 'ptr', 'usize'],
421
+ returns: 'usize',
422
+ },
423
+ castrum_aead_decrypt: {
424
+ args: ['ptr', 'usize', 'ptr', 'usize', 'ptr', 'usize', 'u8', 'ptr', 'usize'],
425
+ returns: 'usize',
426
+ },
427
+ castrum_ws_frame_encode: {
428
+ args: ['u8', 'ptr', 'usize', 'u8', 'u8', 'ptr', 'usize'],
429
+ returns: 'usize',
430
+ },
431
+ castrum_json_patch: {
432
+ args: ['ptr', 'usize', 'ptr', 'usize', 'ptr', 'usize'],
433
+ returns: 'usize',
434
+ },
435
+ castrum_gzip_compress: { args: ['ptr', 'usize', 'u32', 'ptr', 'usize'], returns: 'usize' },
436
+ castrum_gzip_decompress: {
437
+ args: ['ptr', 'usize', 'usize', 'ptr', 'usize'],
438
+ returns: 'usize',
439
+ },
440
+ castrum_brotli_compress: { args: ['ptr', 'usize', 'u32', 'ptr', 'usize'], returns: 'usize' },
441
+ castrum_brotli_decompress: {
442
+ args: ['ptr', 'usize', 'usize', 'ptr', 'usize'],
443
+ returns: 'usize',
444
+ },
445
+ castrum_gzip_isize: { args: ['ptr', 'usize'], returns: 'u32' },
446
+ castrum_http_parse_request_packed: {
447
+ args: ['ptr', 'usize', 'ptr', 'usize'],
448
+ returns: 'usize',
449
+ },
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
+ // ── Excluded-surface additions ──
453
+ castrum_jwt_sign_bytes: {
454
+ args: ['ptr', 'usize', 'ptr', 'usize', 'i64', 'i64', 'ptr', 'usize'],
455
+ returns: 'usize',
456
+ },
457
+ castrum_ws_frame_decode_packed: {
458
+ args: ['ptr', 'usize', 'ptr', 'usize'],
459
+ returns: 'usize',
460
+ },
461
+ castrum_multipart_parse_packed: {
462
+ args: ['ptr', 'usize', 'ptr', 'usize', 'ptr', 'usize'],
463
+ returns: 'usize',
464
+ },
465
+ castrum_form_parse_packed: { args: ['ptr', 'usize', 'ptr', 'usize'], returns: 'usize' },
466
+ castrum_ingress_handle_packed: {
467
+ args: ['usize', 'ptr', 'usize', 'ptr', 'usize', 'ptr', 'usize'],
468
+ returns: 'usize',
469
+ },
470
+ castrum_ingress_layout: { args: ['ptr', 'usize'], returns: 'usize' },
471
+ })
472
+
473
+ const bindings = build(symbols as Record<string, (...a: unknown[]) => unknown>, useBufferLength)
474
+ if (!selfTest(bindings)) {
475
+ if (mode === 'ffi') {
476
+ throw new Error(
477
+ 'CASTRUM_FFI_MODE=ffi: the bun:ffi bind-time self-test failed, so the ' +
478
+ 'primary transport cannot be trusted on this Bun/addon combination. ' +
479
+ 'Unset CASTRUM_FFI_MODE (or use CASTRUM_FFI_MODE=auto) to fall back to napi.',
480
+ )
481
+ }
482
+ return null
483
+ }
484
+ return bindings
485
+ } catch (err) {
486
+ if (mode === 'ffi') {
487
+ const cause = err instanceof Error ? `: ${err.message}` : ''
488
+ throw new Error(`CASTRUM_FFI_MODE=ffi: failed to bind bun:ffi${cause}`)
489
+ }
490
+ return null
491
+ }
492
+ }
493
+
494
+ // Types for the raw dlopen'd functions (bun:ffi converts Uint8Array -> ptr for
495
+ // `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
+
551
+ const flag = (v?: boolean): number => (v ? 1 : 0)
552
+
553
+ /**
554
+ * Write with the C ABI's "needed" convention. The C side returns the EXACT
555
+ * total bytes the result requires: `0` = real error (throw immediately — no
556
+ * re-run, no grow-to-max allocation storm on invalid input), `w > out.length`
557
+ * = buffer too small with `w` the exact required size (allocate once, retry),
558
+ * else `w` is the written count. A miss therefore costs at most ONE retry with
559
+ * an exact buffer — never a doubling loop that re-runs the whole native op
560
+ * (which is what made FFI gzip/brotli/jsonPatch slower than napi).
561
+ */
562
+ function growExact(
563
+ write: (out: Uint8Array) => number,
564
+ initial: number,
565
+ max: number,
566
+ error: string,
567
+ ): Uint8Array {
568
+ let cap = Math.min(Math.max(initial, 16), max)
569
+ for (;;) {
570
+ const out = new Uint8Array(cap)
571
+ const w = Number(write(out))
572
+ if (w === 0) throw new Error(error)
573
+ if (w <= out.length) return out.subarray(0, w)
574
+ if (w > max) throw new Error(error)
575
+ cap = Math.min(w, max)
576
+ }
577
+ }
578
+
579
+ /** Decoder parity: a `0` write on non-empty input throws (napi `Result`). */
580
+ function writeOrThrow(w: number, inputLen: number, label: string): number {
581
+ if (w === 0 && inputLen !== 0) {
582
+ throw new Error(`${label}: invalid input or output buffer too small`)
583
+ }
584
+ return w
585
+ }
586
+
587
+ /** base64 (no-pad) length of `n` bytes. */
588
+ const b64Len = (n: number): number => (n === 0 ? 0 : Math.ceil(n / 3) * 4 - ((3 - (n % 3)) % 3))
589
+
590
+ /**
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).
596
+ */
597
+ function argon2PhcLength(m: number, t: number, p: number, outLen: number): number {
598
+ return (
599
+ 15 + // "$argon2id$v=19$"
600
+ 2 + // "m="
601
+ String(m).length +
602
+ 3 + // ",t="
603
+ String(t).length +
604
+ 3 + // ",p="
605
+ String(p).length +
606
+ 1 + // "$" before the salt
607
+ 22 + // salt b64 (16 bytes)
608
+ 1 + // "$" before the hash
609
+ b64Len(outLen)
610
+ )
611
+ }
612
+
613
+ function build(
614
+ sym: Record<string, (...a: unknown[]) => unknown>,
615
+ useBufferLength: boolean,
616
+ ): BunFFI {
617
+ const crc32 = sym.castrum_crc32 as (...a: unknown[]) => number | bigint
618
+ const fnv = sym.castrum_fnv1a64 as (...a: unknown[]) => number | bigint
619
+ const xxh = sym.castrum_xxh3 as (...a: unknown[]) => number | bigint
620
+ const jsonValid = sym.castrum_json_valid as (...a: unknown[]) => number | bigint
621
+ const hexEncode = sym.castrum_hex_encode as Raw4
622
+ const hexDecode = sym.castrum_hex_decode as Raw4
623
+ const urlEncode = sym.castrum_url_encode as Raw4
624
+ const urlDecode = sym.castrum_url_decode as Raw4
625
+ const validateEmail = sym.castrum_validate_email as (...a: unknown[]) => number | bigint
626
+ const validateUuid = sym.castrum_validate_uuid as (...a: unknown[]) => number | bigint
627
+ const validateIpv4 = sym.castrum_validate_ipv4 as (...a: unknown[]) => number | bigint
628
+ const validateIpv6 = sym.castrum_validate_ipv6 as (...a: unknown[]) => number | bigint
629
+ const jsonSumRaw = sym.castrum_json_sum_ids as Raw4
630
+
631
+ // Single-buffer input fns: with the probe-gated `buffer/buffer_length` ABI the
632
+ // engine reads ptr + byteLength off the SAME TypedArray at call time (an
633
+ // atomic snapshot — pass the view twice, per Bun's docs); with `(ptr,len)` we
634
+ // pass the length explicitly. The branch is on a bind-time constant, so the
635
+ // JIT folds it away at the call site.
636
+ const oneArg = (raw: (...a: unknown[]) => number | bigint, v: Uint8Array): number | bigint =>
637
+ useBufferLength ? raw(v, v) : raw(v, v.length)
638
+ const hmacVerify = sym.castrum_hmac_sha256_verify as Raw6
639
+ const csrfVerify = sym.castrum_csrf_verify as Raw4
640
+ 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
645
+ const base64Encode = sym.castrum_base64_encode as Raw6
646
+ const base64Decode = sym.castrum_base64_decode as Raw6
647
+ 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
651
+ const passwordHash = sym.castrum_password_hash as Raw10
652
+ const passwordHashBcrypt = sym.castrum_password_hash_bcrypt as Raw5
653
+ const pbkdf2 = sym.castrum_pbkdf2_sha256 as Raw8
654
+ const aeadEncrypt = sym.castrum_aead_encrypt as Raw9
655
+ const aeadDecrypt = sym.castrum_aead_decrypt as Raw9
656
+ const wsFrameEncode = sym.castrum_ws_frame_encode as Raw7
657
+ const jsonPatch = sym.castrum_json_patch as Raw6
658
+ const gzipCompress = sym.castrum_gzip_compress as Raw5
659
+ const gzipDecompress = sym.castrum_gzip_decompress as Raw5
660
+ const brotliCompress = sym.castrum_brotli_compress as Raw5
661
+ const brotliDecompress = sym.castrum_brotli_decompress as Raw5
662
+ const gzipIsize = sym.castrum_gzip_isize as Raw2
663
+ const httpParsePacked = sym.castrum_http_parse_request_packed as Raw4
664
+ const queryParsePacked = sym.castrum_query_parse_packed as Raw4
665
+ const cookieParsePacked = sym.castrum_cookie_parse_packed as Raw4
666
+ const jwtSignBytes = sym.castrum_jwt_sign_bytes as Raw8
667
+ const wsFrameDecodePacked = sym.castrum_ws_frame_decode_packed as Raw4
668
+ const multipartParsePacked = sym.castrum_multipart_parse_packed as Raw6
669
+ const formParsePacked = sym.castrum_form_parse_packed as Raw4
670
+ const ingressHandlePacked = sym.castrum_ingress_handle_packed as Raw7
671
+ const ingressLayoutSym = sym.castrum_ingress_layout as Raw2
672
+
673
+ // ── Encoder / decoder `Into` helpers ──────────────────────────────
674
+ const hexEncodeInto = (input: Uint8Array, output: Uint8Array): number => {
675
+ // Mirror napi error semantics: a too-small buffer throws, not returns 0.
676
+ // Hex encode always writes exactly `input.length * 2` bytes on success.
677
+ if (output.length < input.length * 2) {
678
+ throw new Error('hex encode: output buffer too small')
679
+ }
680
+ return Number(hexEncode(input, input.length, output, output.length))
681
+ }
682
+
683
+ const urlEncodeInto = (input: Uint8Array, output: Uint8Array): number => {
684
+ const w = Number(urlEncode(input, input.length, output, output.length))
685
+ // Every input byte encodes to >= 1 output byte, so a 0 write on non-empty
686
+ // input means the buffer was too small (napi throws there too). Empty
687
+ // input legitimately writes 0.
688
+ if (w === 0 && input.length !== 0) {
689
+ throw new Error('url encode: output buffer too small')
690
+ }
691
+ return w
692
+ }
693
+
694
+ const hexDecodeInto = (input: Uint8Array, output: Uint8Array): number => {
695
+ const w = Number(hexDecode(input, input.length, output, output.length))
696
+ return writeOrThrow(w, input.length, 'hex decode')
697
+ }
698
+
699
+ const urlDecodeInto = (input: Uint8Array, output: Uint8Array): number => {
700
+ const w = Number(urlDecode(input, input.length, output, output.length))
701
+ return writeOrThrow(w, input.length, 'url decode')
702
+ }
703
+
704
+ const base64DecodeInto = (
705
+ input: Uint8Array,
706
+ output: Uint8Array,
707
+ urlSafe?: boolean,
708
+ padding?: boolean,
709
+ ): number => {
710
+ // napi defaults: urlSafe=false, padding=true (rust/crypto/base64.rs).
711
+ const w = Number(
712
+ base64Decode(
713
+ input,
714
+ input.length,
715
+ output,
716
+ output.length,
717
+ flag(urlSafe),
718
+ flag(padding ?? true),
719
+ ),
720
+ )
721
+ return writeOrThrow(w, input.length, 'base64 decode')
722
+ }
723
+
724
+ const base64EncodeInto = (
725
+ input: Uint8Array,
726
+ output: Uint8Array,
727
+ urlSafe?: boolean,
728
+ padding?: boolean,
729
+ ): number => {
730
+ // napi defaults: urlSafe=false, padding=true.
731
+ const w = Number(
732
+ base64Encode(
733
+ input,
734
+ input.length,
735
+ output,
736
+ output.length,
737
+ flag(urlSafe),
738
+ flag(padding ?? true),
739
+ ),
740
+ )
741
+ // Empty input legitimately writes 0 bytes — only a 0 write on NON-empty
742
+ // input is a real error (same convention as the decode/etag paths).
743
+ return writeOrThrow(w, input.length, 'base64 encode')
744
+ }
745
+
746
+ 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) {
749
+ throw new Error('etag: output buffer too small')
750
+ }
751
+ return w
752
+ }
753
+
754
+ return {
755
+ crc32: (input) => Number(oneArg(crc32, input)) >>> 0,
756
+ fnv1a64: (input) => BigInt(oneArg(fnv, input)),
757
+ xxh3: (input) => BigInt(oneArg(xxh, input)),
758
+ jsonValid: (input) => Number(oneArg(jsonValid, input)) === 1,
759
+ hexEncode(input) {
760
+ const out = new Uint8Array(input.length * 2)
761
+ const w = hexEncodeInto(input, out)
762
+ return w === input.length * 2 ? out : out.subarray(0, w)
763
+ },
764
+ hexEncodeInto,
765
+ urlEncode(input) {
766
+ // RFC 3986 worst case is 3 bytes per input byte (`%XX`).
767
+ const out = new Uint8Array(input.length * 3)
768
+ const w = urlEncodeInto(input, out)
769
+ return out.subarray(0, w)
770
+ },
771
+ urlEncodeInto,
772
+
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,
777
+ jsonSumIds: (input) => {
778
+ // Packed [u8 ok][i64 sum LE] output (9 B): ok=1 → valid array (the sum
779
+ // 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))
782
+ if (w === 0) {
783
+ throw new Error('json sum ids: output buffer too small')
784
+ }
785
+ if (out[0] === 0) {
786
+ // Mirrors the napi error phrasing (serde: "expected an array of objects
787
+ // with numeric ids") so both transports throw the same message.
788
+ throw new Error('json sum ids: expected an array of objects with numeric ids')
789
+ }
790
+ return new DataView(out.buffer, out.byteOffset, out.byteLength).getBigInt64(1, true)
791
+ },
792
+ hmacSha256Verify: (key, data, signature) =>
793
+ Number(hmacVerify(key, key.length, data, data.length, signature, signature.length)) === 1,
794
+ csrfVerify: (token, secret) =>
795
+ Number(csrfVerify(token, token.length, secret, secret.length)) === 1,
796
+ passwordVerify: (password, phc) =>
797
+ Number(passwordVerify(password, password.length, phc, phc.length)) === 1,
798
+ passwordVerifyBcrypt: (password, phc) =>
799
+ Number(passwordVerifyBcrypt(password, password.length, phc, phc.length)) === 1,
800
+
801
+ hexDecode(input) {
802
+ const out = new Uint8Array(Math.floor(input.length / 2))
803
+ const w = hexDecodeInto(input, out)
804
+ return out.subarray(0, w)
805
+ },
806
+ hexDecodeInto,
807
+ urlDecode(input) {
808
+ const out = new Uint8Array(input.length)
809
+ const w = urlDecodeInto(input, out)
810
+ return out.subarray(0, w)
811
+ },
812
+ urlDecodeInto,
813
+ base64Decode(input, urlSafe, padding) {
814
+ const out = new Uint8Array(Math.ceil((input.length * 3) / 4))
815
+ const w = base64DecodeInto(input, out, urlSafe, padding)
816
+ return out.subarray(0, w)
817
+ },
818
+ base64DecodeInto,
819
+
820
+ wsAcceptKey(key) {
821
+ const out = new Uint8Array(28)
822
+ const w = Number(wsAcceptKey(key, key.length, out, out.length))
823
+ if (w === 0) {
824
+ throw new Error('ws accept key: bad key or output buffer too small')
825
+ }
826
+ return out.subarray(0, w)
827
+ },
828
+ etag(data, weak) {
829
+ const out = new Uint8Array(12)
830
+ const w = etagInto(data, out, weak)
831
+ return out.subarray(0, w)
832
+ },
833
+ 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')
841
+ }
842
+ return out.subarray(0, w)
843
+ },
844
+ base64Encode(input, urlSafe, padding) {
845
+ const out = new Uint8Array(Math.ceil(input.length / 3) * 4)
846
+ const w = base64EncodeInto(input, out, urlSafe, padding)
847
+ return out.subarray(0, w)
848
+ },
849
+ base64EncodeInto,
850
+ hmacSha256(key, data) {
851
+ const out = new Uint8Array(64)
852
+ const w = Number(hmacSha256(key, key.length, data, data.length, out, out.length))
853
+ if (w === 0) {
854
+ throw new Error('hmac sha256: output buffer too small')
855
+ }
856
+ return out.subarray(0, w)
857
+ },
858
+ hmacSha256Into(key, data, output) {
859
+ if (output.length < 64) {
860
+ throw new Error('hmac sha256: output buffer too small')
861
+ }
862
+ const w = Number(hmacSha256(key, key.length, data, data.length, output, output.length))
863
+ if (w === 0) {
864
+ throw new Error('hmac sha256: output buffer too small')
865
+ }
866
+ return w
867
+ },
868
+ 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)
876
+ },
877
+ 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
+ }
882
+ const w = Number(
883
+ signCookie(value, value.length, secret, secret.length, output, output.length),
884
+ )
885
+ if (w === 0) {
886
+ throw new Error('sign cookie: output buffer too small')
887
+ }
888
+ return w
889
+ },
890
+ 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)
894
+ },
895
+ csrfToken(secret) {
896
+ const out = new Uint8Array(129)
897
+ const w = Number(csrfToken(secret, secret.length, out, out.length))
898
+ if (w === 0) {
899
+ throw new Error('csrf token: output buffer too small or random source failed')
900
+ }
901
+ return out.subarray(0, w)
902
+ },
903
+ passwordHash(password, salt, mCost, tCost, pCost, outLen) {
904
+ // Pre-size with the EXACT PHC string length (computable from the params),
905
+ // so the hash runs once — a grow-retry would re-run the whole argon2
906
+ // 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,
921
+ ),
922
+ ),
923
+ argon2PhcLength(mCost, tCost, pCost, outLen),
924
+ 2 * 1024 * 1024,
925
+ 'password hash: output buffer too small',
926
+ )
927
+ },
928
+ passwordHashBcrypt(password, cost) {
929
+ // `$2b$CC$` + 22 salt chars + 31 hash chars = 60 chars.
930
+ const out = new Uint8Array(64)
931
+ const w = Number(passwordHashBcrypt(password, password.length, cost, out, out.length))
932
+ if (w === 0) {
933
+ throw new Error('password hash bcrypt: output buffer too small')
934
+ }
935
+ return out.subarray(0, w)
936
+ },
937
+ pbkdf2Sha256(password, salt, rounds, dkLen) {
938
+ // Rust clamps dkLen to [1, 1MiB] (PBKDF2_MIN_LEN/MAX_LEN) AFTER sizing its
939
+ // own buffer — so pre-clamp here so dkLen 0 still yields a 1-byte result.
940
+ const dk = Math.min(Math.max(dkLen, 1), 1024 * 1024)
941
+ const out = new Uint8Array(dk)
942
+ const w = Number(
943
+ pbkdf2(password, password.length, salt, salt.length, rounds, dkLen, out, out.length),
944
+ )
945
+ if (w === 0) {
946
+ throw new Error('pbkdf2: output buffer too small')
947
+ }
948
+ return out.subarray(0, w)
949
+ },
950
+ aeadEncrypt(key, nonce, plaintext, algorithm = 0) {
951
+ // ciphertext + 16-byte auth tag.
952
+ const out = new Uint8Array(plaintext.length + 16)
953
+ const w = Number(
954
+ aeadEncrypt(
955
+ key,
956
+ key.length,
957
+ nonce,
958
+ nonce.length,
959
+ plaintext,
960
+ plaintext.length,
961
+ algorithm,
962
+ out,
963
+ out.length,
964
+ ),
965
+ )
966
+ if (w === 0) {
967
+ throw new Error('aead encrypt: output buffer too small or bad parameters')
968
+ }
969
+ return out.subarray(0, w)
970
+ },
971
+ aeadEncryptInto(key, nonce, plaintext, output, algorithm = 0) {
972
+ const need = plaintext.length + 16
973
+ if (output.length < need) {
974
+ throw new Error('aead encrypt: output buffer too small')
975
+ }
976
+ const w = Number(
977
+ aeadEncrypt(
978
+ key,
979
+ key.length,
980
+ nonce,
981
+ nonce.length,
982
+ plaintext,
983
+ plaintext.length,
984
+ algorithm,
985
+ output,
986
+ output.length,
987
+ ),
988
+ )
989
+ if (w === 0) {
990
+ throw new Error('aead encrypt: output buffer too small or bad parameters')
991
+ }
992
+ return w
993
+ },
994
+ aeadDecrypt(key, nonce, ciphertext, algorithm = 0) {
995
+ const out = new Uint8Array(ciphertext.length)
996
+ const w = Number(
997
+ aeadDecrypt(
998
+ key,
999
+ key.length,
1000
+ nonce,
1001
+ nonce.length,
1002
+ ciphertext,
1003
+ ciphertext.length,
1004
+ algorithm,
1005
+ out,
1006
+ out.length,
1007
+ ),
1008
+ )
1009
+ return w === 0 ? null : out.subarray(0, w)
1010
+ },
1011
+
1012
+ wsFrameEncode(opcode, payload, mask, fin) {
1013
+ // Header (max 10) + payload + mask key (4).
1014
+ const out = new Uint8Array(payload.length + 14)
1015
+ const w = Number(
1016
+ wsFrameEncode(opcode, payload, payload.length, flag(mask), flag(fin), out, out.length),
1017
+ )
1018
+ if (w === 0) {
1019
+ throw new Error('ws frame encode: output buffer too small')
1020
+ }
1021
+ return out.subarray(0, w)
1022
+ },
1023
+ wsFrameEncodeInto(opcode, payload, mask, fin, output) {
1024
+ // Header (max 10) + payload + mask key (4).
1025
+ const need = payload.length + 14
1026
+ if (output.length < need) {
1027
+ throw new Error('ws frame encode: output buffer too small')
1028
+ }
1029
+ const w = Number(
1030
+ wsFrameEncode(
1031
+ opcode,
1032
+ payload,
1033
+ payload.length,
1034
+ flag(mask),
1035
+ flag(fin),
1036
+ output,
1037
+ output.length,
1038
+ ),
1039
+ )
1040
+ if (w === 0) {
1041
+ throw new Error('ws frame encode: output buffer too small')
1042
+ }
1043
+ return w
1044
+ },
1045
+ 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',
1051
+ )
1052
+ },
1053
+ gzipCompress(data, level = 6) {
1054
+ // The C ABI streams the compressed output directly into `out` (no internal
1055
+ // Vec — see rust/payload/compress.rs gzip_compress_into). Cap the initial
1056
+ // so large compressible inputs don't pay a full-size allocation (measured:
1057
+ // a 75 KiB input compressed to <1 KiB — COMPRESS_INITIAL_CAP is plenty and
1058
+ // single-pass). growExact handles incompressible data with at most one
1059
+ // exact retry (no re-run loop).
1060
+ return growExact(
1061
+ (out) => Number(gzipCompress(data, data.length, Math.min(level, 9), out, out.length)),
1062
+ Math.min(data.length + COMPRESS_HEADROOM, COMPRESS_INITIAL_CAP),
1063
+ Math.max(data.length * 2 + COMPRESS_HEADROOM, COMPRESS_MAX_CAP),
1064
+ 'gzip compress: output buffer too small',
1065
+ )
1066
+ },
1067
+ gzipCompressInto(data, output, level = 6) {
1068
+ const w = Number(gzipCompress(data, data.length, Math.min(level, 9), output, output.length))
1069
+ // Needed-size convention: w > output.length = too small; w === 0 = real error.
1070
+ if (w === 0) {
1071
+ throw new Error('gzip compress: invalid input')
1072
+ }
1073
+ if (w > output.length) {
1074
+ throw new Error('gzip compress: output buffer too small')
1075
+ }
1076
+ return w
1077
+ },
1078
+ gzipDecompress(data, maxDecompressed) {
1079
+ const max = maxDecompressed ?? MAX_DECOMPRESSED
1080
+ // Pre-size from the gzip ISIZE trailer via the C probe (exact for
1081
+ // single-member streams) so the happy path is a single native pass; fall
1082
+ // back to a multiplier guess. growExact handles any residual miss with
1083
+ // one exact retry, and invalid input throws immediately (no 64 MiB
1084
+ // grow-to-max allocation storm).
1085
+ const isize = Number(gzipIsize(data, data.length))
1086
+ const initial =
1087
+ isize !== 0
1088
+ ? Math.min(isize, max)
1089
+ : Math.min(data.length * DECOMPRESS_GUESS_MULTIPLIER_GZIP, DECOMPRESS_FALLBACK_CAP)
1090
+ return growExact(
1091
+ (out) => Number(gzipDecompress(data, data.length, max, out, out.length)),
1092
+ initial,
1093
+ max,
1094
+ 'gzip decompress: invalid stream or exceeded max decompressed size',
1095
+ )
1096
+ },
1097
+ brotliCompress(data, quality = 5) {
1098
+ // Same cap rationale as gzipCompress (streaming core); growExact for
1099
+ // incompressible data.
1100
+ return growExact(
1101
+ (out) => Number(brotliCompress(data, data.length, Math.min(quality, 11), out, out.length)),
1102
+ Math.min(data.length + COMPRESS_HEADROOM, COMPRESS_INITIAL_CAP),
1103
+ Math.max(data.length * 2 + COMPRESS_HEADROOM, COMPRESS_MAX_CAP),
1104
+ 'brotli compress: output buffer too small',
1105
+ )
1106
+ },
1107
+ brotliCompressInto(data, output, quality = 5) {
1108
+ const w = Number(
1109
+ brotliCompress(data, data.length, Math.min(quality, 11), output, output.length),
1110
+ )
1111
+ // Needed-size convention: w > output.length = too small; w === 0 = real error.
1112
+ if (w === 0) {
1113
+ throw new Error('brotli compress: invalid input')
1114
+ }
1115
+ if (w > output.length) {
1116
+ throw new Error('brotli compress: output buffer too small')
1117
+ }
1118
+ return w
1119
+ },
1120
+ brotliDecompress(data, maxDecompressed) {
1121
+ const max = maxDecompressed ?? MAX_DECOMPRESSED
1122
+ // Brotli has no cheap trailer size: DECOMPRESS_GUESS_MULTIPLIER_BROTLI×
1123
+ // initial covers typical JSON/text ratios (~10-30x) while the fallback
1124
+ // cap bounds over-allocation on large streams; growExact handles
1125
+ // higher-ratio streams with one exact retry.
1126
+ return growExact(
1127
+ (out) => Number(brotliDecompress(data, data.length, max, out, out.length)),
1128
+ Math.min(
1129
+ Math.max(data.length * DECOMPRESS_GUESS_MULTIPLIER_BROTLI, DECOMPRESS_MIN_INITIAL),
1130
+ DECOMPRESS_FALLBACK_CAP,
1131
+ ),
1132
+ max,
1133
+ 'brotli decompress: invalid stream or exceeded max decompressed size',
1134
+ )
1135
+ },
1136
+
1137
+ httpParseRequestPackedInto(input, output) {
1138
+ const w = Number(httpParsePacked(input, input.length, output, output.length))
1139
+ if (w === 0 && input.length !== 0) {
1140
+ throw new Error('http parse: output buffer too small or malformed request')
1141
+ }
1142
+ return w
1143
+ },
1144
+ queryParsePackedInto(input, output) {
1145
+ const w = Number(queryParsePacked(input, input.length, output, output.length))
1146
+ if (w === 0 && input.length !== 0) {
1147
+ throw new Error('query parse: output buffer too small')
1148
+ }
1149
+ return w
1150
+ },
1151
+ cookieParsePackedInto(input, output) {
1152
+ const w = Number(cookieParsePacked(input, input.length, output, output.length))
1153
+ if (w === 0 && input.length !== 0) {
1154
+ throw new Error('cookie parse: output buffer too small')
1155
+ }
1156
+ return w
1157
+ },
1158
+
1159
+ // ── Excluded-surface additions ──────────────────────────────────
1160
+ 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,
1187
+ ),
1188
+ 1024 * 1024,
1189
+ 'jwt sign: output buffer too small',
1190
+ )
1191
+ },
1192
+ wsFrameDecodePacked(data) {
1193
+ // Max packed output = 6-byte header + payload.
1194
+ const out = new Uint8Array(data.length + 6)
1195
+ const w = Number(wsFrameDecodePacked(data, data.length, out, out.length))
1196
+ return w === 0 ? null : out.subarray(0, w)
1197
+ },
1198
+ multipartParsePacked(body, boundary) {
1199
+ return growExact(
1200
+ (out) =>
1201
+ Number(
1202
+ multipartParsePacked(body, body.length, boundary, boundary.length, out, out.length),
1203
+ ),
1204
+ Math.min(body.length + boundary.length + 64, 64 * 1024),
1205
+ 128 * 1024 * 1024,
1206
+ 'multipart parse: output buffer too small',
1207
+ )
1208
+ },
1209
+ formParsePackedInto(input, output) {
1210
+ const w = Number(formParsePacked(input, input.length, output, output.length))
1211
+ if (w === 0 && input.length !== 0) {
1212
+ throw new Error('form parse: output buffer too small')
1213
+ }
1214
+ return w
1215
+ },
1216
+ ingressHandlePacked(inner, input, body, output) {
1217
+ const w = Number(
1218
+ ingressHandlePacked(
1219
+ inner,
1220
+ input,
1221
+ input.length,
1222
+ body ?? EMPTY_VIEW,
1223
+ body?.length ?? 0,
1224
+ output,
1225
+ output.length,
1226
+ ),
1227
+ )
1228
+ if (w === 0) {
1229
+ throw new Error('ingress handle: output buffer too small or pipeline error')
1230
+ }
1231
+ return w
1232
+ },
1233
+ ingressLayout(out) {
1234
+ const w = Number(ingressLayoutSym(out, out.length))
1235
+ if (w !== out.length) {
1236
+ throw new Error('ingress layout: output buffer too small')
1237
+ }
1238
+ return w
1239
+ },
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
+
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
1538
+ }
1539
+
1540
+ return true
1541
+ }