castrum 0.7.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 (207) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +154 -368
  3. package/castrum.linux-x64-gnu.node +0 -0
  4. package/dist/index.d.ts +2763 -0
  5. package/dist/index.js +8707 -0
  6. package/docs/API.md +337 -0
  7. package/docs/ARCHITECTURE.md +507 -0
  8. package/docs/BENCHMARKS.md +343 -0
  9. package/docs/CASE_STUDY.md +240 -0
  10. package/docs/ENVIRONMENT.md +81 -0
  11. package/docs/FFI_BUN_GUIDE.md +327 -0
  12. package/docs/GETTING_STARTED.md +192 -0
  13. package/docs/INGRESS-ROUTER.md +81 -0
  14. package/docs/INGRESS.md +746 -0
  15. package/docs/NATIVE-ROUTE.md +53 -0
  16. package/docs/REPO_MAP.md +279 -0
  17. package/docs/adr/0001-two-wire-formats.md +39 -0
  18. package/docs/adr/0002-zero-dom-fast-schema.md +38 -0
  19. package/docs/adr/0003-bun-builtins-delegation.md +43 -0
  20. package/docs/bun-builtins-decision-matrix.md +115 -0
  21. package/index.ts +44 -8
  22. package/package.json +98 -33
  23. package/src/ingress/body.ts +289 -0
  24. package/src/ingress/constants.ts +220 -48
  25. package/src/ingress/context.ts +136 -0
  26. package/src/ingress/decode/baked-result.ts +121 -0
  27. package/src/ingress/decode/fast-result.ts +160 -0
  28. package/src/ingress/decode/packed-sections.ts +56 -0
  29. package/src/ingress/decode/result-base.ts +172 -0
  30. package/src/ingress/errors.ts +72 -0
  31. package/src/ingress/fast.ts +152 -1021
  32. package/src/ingress/handlers.ts +639 -1001
  33. package/src/ingress/headers/baked-templates.ts +102 -0
  34. package/src/ingress/headers/cors.ts +44 -0
  35. package/src/ingress/headers/fast-templates.ts +238 -0
  36. package/src/ingress/headers/hsts.ts +83 -0
  37. package/src/ingress/health.ts +68 -0
  38. package/src/ingress/index.ts +142 -405
  39. package/src/ingress/metrics.ts +132 -0
  40. package/src/ingress/options.ts +197 -0
  41. package/src/ingress/packing/gather-raw-headers.ts +139 -0
  42. package/src/ingress/packing/header-packing.ts +75 -0
  43. package/src/ingress/packing/input-packer.ts +164 -0
  44. package/src/ingress/packing/scratch.ts +50 -0
  45. package/src/ingress/packing/select-headers.ts +102 -0
  46. package/src/ingress/response/error-bodies.ts +75 -0
  47. package/src/ingress/response/terminal.ts +59 -0
  48. package/src/ingress/router.ts +237 -0
  49. package/src/ingress/routes/common.ts +79 -0
  50. package/src/ingress/routes/delete.ts +14 -0
  51. package/src/ingress/routes/echo.ts +107 -0
  52. package/src/ingress/routes/fallback.ts +23 -0
  53. package/src/ingress/routes/head.ts +27 -0
  54. package/src/ingress/routes/index.ts +11 -0
  55. package/src/ingress/routes/json-write.ts +129 -0
  56. package/src/ingress/routes/options.ts +26 -0
  57. package/src/ingress/routes/read.ts +35 -0
  58. package/src/ingress/routes/responder.ts +159 -0
  59. package/src/ingress/server-node.ts +376 -0
  60. package/src/ingress/server.ts +475 -0
  61. package/src/ingress/shared.ts +132 -0
  62. package/src/ingress/status.ts +97 -0
  63. package/src/ingress/types.ts +191 -0
  64. package/src/integration/batch.ts +58 -0
  65. package/src/integration/index.ts +29 -0
  66. package/src/integration/pipeline.ts +220 -0
  67. package/src/integration/streaming.ts +78 -0
  68. package/src/integration/websocket.ts +93 -0
  69. package/src/loader/batch.ts +137 -0
  70. package/src/loader/cost.ts +105 -0
  71. package/src/loader/index.ts +745 -0
  72. package/src/loader/ops.ts +657 -0
  73. package/src/loader/state.ts +84 -0
  74. package/src/loader/types.ts +145 -0
  75. package/src/native/ffi/constants.ts +58 -0
  76. package/src/native/ffi/selftest.ts +729 -0
  77. package/src/native/ffi/types.ts +474 -0
  78. package/src/native/ffi.ts +1775 -0
  79. package/src/native/index.ts +8 -207
  80. package/src/native/loader.ts +240 -0
  81. package/src/native/types/instances.ts +274 -0
  82. package/src/native/types.ts +401 -0
  83. package/src/runtime/builtins.ts +131 -0
  84. package/src/runtime/codec.ts +147 -0
  85. package/src/runtime/detect.ts +36 -0
  86. package/src/runtime/index.ts +28 -0
  87. package/src/runtime/native.ts +35 -0
  88. package/src/runtime/server.ts +31 -0
  89. package/src/runtime/transport.ts +64 -0
  90. package/src/runtime/types.ts +173 -0
  91. package/src/runtime/uuid.ts +45 -0
  92. package/src/runtime/websocket.ts +30 -0
  93. package/src/rust-ffi/addon.ts +10 -0
  94. package/src/rust-ffi/batch/build.ts +155 -0
  95. package/src/rust-ffi/batch/index.ts +8 -0
  96. package/src/rust-ffi/batch/types.ts +92 -0
  97. package/src/rust-ffi/client.ts +87 -0
  98. package/src/rust-ffi/context.ts +193 -0
  99. package/src/rust-ffi/index.ts +51 -541
  100. package/src/rust-ffi/into.ts +21 -0
  101. package/src/rust-ffi/options.ts +113 -0
  102. package/src/rust-ffi/packed.ts +253 -0
  103. package/src/rust-ffi/proven.ts +28 -0
  104. package/src/rust-ffi/scalar/crypto.ts +346 -0
  105. package/src/rust-ffi/scalar/factories.ts +459 -0
  106. package/src/rust-ffi/scalar/hashing.ts +74 -0
  107. package/src/rust-ffi/scalar/http.ts +337 -0
  108. package/src/rust-ffi/scalar/index.ts +31 -0
  109. package/src/rust-ffi/scalar/interface.ts +325 -0
  110. package/src/rust-ffi/scalar/json-packed.ts +175 -0
  111. package/src/rust-ffi/scalar/json.ts +61 -0
  112. package/src/rust-ffi/scalar/payload.ts +241 -0
  113. package/src/rust-ffi/text.ts +121 -0
  114. package/src/selection.json +194 -0
  115. package/src/selection.ts +73 -0
  116. package/src/shared/adaptive.ts +67 -0
  117. package/src/shared/buffer-pool.ts +264 -0
  118. package/src/shared/bytes.ts +108 -9
  119. package/src/shared/codec.ts +78 -0
  120. package/src/shared/env.ts +20 -0
  121. package/src/shared/log.ts +75 -0
  122. package/src/shared/metrics.ts +247 -0
  123. package/src/shared/packed/index.ts +10 -0
  124. package/src/shared/packed/parsers.ts +31 -0
  125. package/src/shared/packed/schema.ts +23 -0
  126. package/src/shared/packed/wire.ts +504 -0
  127. package/src/shared/proven.ts +289 -0
  128. package/src/shared/request-id.ts +67 -0
  129. package/src/shared/response.ts +104 -0
  130. package/src/shared/runtime.ts +29 -0
  131. package/src/shared/trace.ts +81 -0
  132. package/src/shared/uuid.ts +15 -0
  133. package/Cargo.toml +0 -73
  134. package/bench.ts +0 -3
  135. package/rust/batch.rs +0 -195
  136. package/rust/cookie_parser.rs +0 -70
  137. package/rust/cors.rs +0 -263
  138. package/rust/hashing.rs +0 -164
  139. package/rust/headers.rs +0 -234
  140. package/rust/hmac_sha256.rs +0 -113
  141. package/rust/http_parser.rs +0 -132
  142. package/rust/ingress.rs +0 -616
  143. package/rust/ingress_constants.rs +0 -89
  144. package/rust/ip_trust.rs +0 -185
  145. package/rust/json_ops.rs +0 -66
  146. package/rust/json_patch_ops.rs +0 -25
  147. package/rust/json_schema.rs +0 -151
  148. package/rust/json_ser.rs +0 -429
  149. package/rust/lib.rs +0 -48
  150. package/rust/method.rs +0 -141
  151. package/rust/mime_lookup.rs +0 -76
  152. package/rust/output.rs +0 -238
  153. package/rust/proxy.rs +0 -90
  154. package/rust/query_parser.rs +0 -141
  155. package/rust/random_token.rs +0 -52
  156. package/rust/rate_limit.rs +0 -203
  157. package/rust/terminal.rs +0 -95
  158. package/rust/unit_tests.rs +0 -843
  159. package/rust/url_codec.rs +0 -133
  160. package/rust/util.rs +0 -537
  161. package/rust/validation.rs +0 -107
  162. package/rust/websocket.rs +0 -23
  163. package/src/baseline/index.ts +0 -12
  164. package/src/baseline/tasks/cookie.ts +0 -22
  165. package/src/baseline/tasks/hashing.ts +0 -18
  166. package/src/baseline/tasks/hmac.ts +0 -38
  167. package/src/baseline/tasks/http.ts +0 -62
  168. package/src/baseline/tasks/json-patch.ts +0 -12
  169. package/src/baseline/tasks/json.ts +0 -50
  170. package/src/baseline/tasks/mime.ts +0 -5
  171. package/src/baseline/tasks/query.ts +0 -14
  172. package/src/baseline/tasks/token.ts +0 -15
  173. package/src/baseline/tasks/url.ts +0 -11
  174. package/src/baseline/tasks/validation.ts +0 -19
  175. package/src/baseline/tasks/websocket.ts +0 -14
  176. package/src/bench/assert.ts +0 -70
  177. package/src/bench/checks.ts +0 -227
  178. package/src/bench/checksum.ts +0 -27
  179. package/src/bench/comparisons.ts +0 -70
  180. package/src/bench/concurrent-worker.ts +0 -135
  181. package/src/bench/fixtures.ts +0 -233
  182. package/src/bench/index.ts +0 -2
  183. package/src/bench/measure.ts +0 -177
  184. package/src/bench/now.ts +0 -3
  185. package/src/bench/report.ts +0 -79
  186. package/src/bench/run.ts +0 -57
  187. package/src/bench/tasks/complex.ts +0 -232
  188. package/src/bench/tasks/concurrent.ts +0 -213
  189. package/src/bench/tasks/cookie.ts +0 -44
  190. package/src/bench/tasks/hashing.ts +0 -33
  191. package/src/bench/tasks/hmac.ts +0 -36
  192. package/src/bench/tasks/http.ts +0 -50
  193. package/src/bench/tasks/index.ts +0 -52
  194. package/src/bench/tasks/json-patch.ts +0 -21
  195. package/src/bench/tasks/json.ts +0 -33
  196. package/src/bench/tasks/mime.ts +0 -21
  197. package/src/bench/tasks/query.ts +0 -44
  198. package/src/bench/tasks/stress.ts +0 -142
  199. package/src/bench/tasks/token.ts +0 -20
  200. package/src/bench/tasks/url.ts +0 -34
  201. package/src/bench/tasks/validation.ts +0 -57
  202. package/src/bench/tasks/websocket.ts +0 -21
  203. package/src/bench/types.ts +0 -60
  204. package/src/data/json-rows.ts +0 -33
  205. package/src/ingress/packed-input.ts +0 -59
  206. package/src/shared/json.ts +0 -18
  207. package/src/shared/packed.ts +0 -316
@@ -0,0 +1,1775 @@
1
+ // src/native/ffi.ts — Bun-only C-ABI fast path via `bun:ffi` (transport core).
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
+ //
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.
13
+ //
14
+ // Safety / correctness strategy:
15
+ // - Lazily bound (no addon/ffi work until first use).
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).
21
+ // - Never called under Node (falls back to napi immediately).
22
+ //
23
+ // Buffer ABI: input/out pointers are `(ptr, len)` pairs; the JS wrapper passes
24
+ // `(view, view.length)` and bun:ffi converts the TypedArray to its pointer.
25
+
26
+ import type { FFITypeOrString } from 'bun:ffi'
27
+ import { decodeUtf8, encodeUtf8 } from '../shared/codec'
28
+ import { resolveEnvVar } from '../shared/env'
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'
58
+ // Static import: loader.ts has no side effects (path resolution only — no
59
+ // dlopen), so this adds zero import-time cost while letting `bind()` reuse the
60
+ // exact same resolved `.node` path the napi fallback uses (the shared seam).
61
+ import { getAddonPath } from './loader'
62
+
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'
66
+
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
75
+
76
+ // ── Transport selection (CASTRUM_FFI_MODE) ───────────────────────
77
+
78
+ function resolveFfiMode(): FfiMode {
79
+ const raw = resolveEnvVar('CASTRUM_FFI_MODE')
80
+ switch (raw) {
81
+ case 'ffi':
82
+ return 'ffi'
83
+ case 'napi':
84
+ return 'napi'
85
+ default:
86
+ return 'auto'
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Lazily bind the Bun ffi fast path, or return `null` when unavailable
92
+ * (Node, `CASTRUM_FFI_MODE=napi`, missing symbols, or a failed self-test).
93
+ * `undefined` caches "not yet attempted".
94
+ *
95
+ * Under `CASTRUM_FFI_MODE=ffi` a bind/self-test failure THROWS instead of
96
+ * returning null — the caller asked for the primary transport explicitly.
97
+ */
98
+ export function getBunFFI(): BunFFI | null {
99
+ if (cached !== undefined) {
100
+ return cached
101
+ }
102
+ cached = bind()
103
+ return cached
104
+ }
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
+
123
+ /**
124
+ * Probe whether this Bun build accepts the `buffer`/`buffer_length` ABI pair in
125
+ * `dlopen`. Bun's docs list `buffer_length` as engine-native (dlopen-supported),
126
+ * but an earlier canary threw "invalid ABI type" for it. If that regressed (or
127
+ * a future Bun removes it), we silently keep explicit `(ptr, len)` pairs. The
128
+ * full bind-time self-test is the safety net either way.
129
+ */
130
+ function probeBufferLength(dlopen: typeof import('bun:ffi')['dlopen'], path: string): boolean {
131
+ try {
132
+ const { symbols, close } = dlopen(path, {
133
+ // bun-types lacks the `buffer_length` literal — cast it; runtime support
134
+ // is exactly what this probe is verifying.
135
+ castrum_crc32: {
136
+ args: ['buffer', 'buffer_length'] as unknown as readonly FFITypeOrString[],
137
+ returns: 'u32',
138
+ },
139
+ })
140
+ // `buffer_length` is `buffer`'s length twin — pass the SAME view twice
141
+ // (the engine reads ptr + byteLength off that object at call time).
142
+ const view = new Uint8Array([1, 2, 3])
143
+ const out = (symbols as Record<string, (a: unknown, b: unknown) => unknown>).castrum_crc32?.(
144
+ view,
145
+ view,
146
+ )
147
+ close()
148
+ return typeof out === 'number' && out >= 0
149
+ } catch {
150
+ return false
151
+ }
152
+ }
153
+
154
+ function bind(): BunFFI | null {
155
+ bufferAbiMode = null // reset — the mode below is only valid for a live bind
156
+ const mode = resolveFfiMode()
157
+ if (!isBun()) {
158
+ if (mode === 'ffi') {
159
+ throw new Error(
160
+ 'CASTRUM_FFI_MODE=ffi is invalid here: bun:ffi is a Bun-only transport ' +
161
+ '(this process is not Bun). Use CASTRUM_FFI_MODE=auto (default) so Node ' +
162
+ 'uses the napi fallback.',
163
+ )
164
+ }
165
+ return null
166
+ }
167
+ if (mode === 'napi') {
168
+ return null
169
+ }
170
+ try {
171
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
172
+ const { dlopen } = require('bun:ffi') as typeof import('bun:ffi')
173
+ // Resolve the SAME addon file napi uses (./loader.ts — statically imported
174
+ // above; it only resolves the path, it never dlopens the addon).
175
+ const path = getAddonPath()
176
+
177
+ // Probe `buffer`/`buffer_length` (the engine reads ptr + len off the SAME
178
+ // TypedArray at call time — an atomic snapshot) once; when supported we use
179
+ // it for the input-only hot fns (one JS arg instead of two), else `(ptr,len)`.
180
+ const useBufferLength = probeBufferLength(dlopen, path)
181
+ bufferAbiMode = useBufferLength ? 'buffer-pair' : 'ptr-len'
182
+
183
+ // String ABI specs ("ptr"/"usize"/"u32"...): accepted by bun:ffi and typed
184
+ // via `FFITypeOrString` in bun-types. usize == uint64 on the host ABI.
185
+ // Symbol keys must be the exact `#[no_mangle] extern "C"` names in
186
+ // rust/ffi.rs. Every `(ptr,len)` pair is passed as `(view, view.length)`.
187
+ // The 8 input-only single-buffer fns use `inputAbi`; everything else keeps
188
+ // `(ptr,len)` because the output length is the caller's write cap.
189
+ const inputAbi: readonly FFITypeOrString[] = useBufferLength
190
+ ? (['buffer', 'buffer_length'] as unknown as readonly FFITypeOrString[])
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
221
+ const { symbols } = dlopen(path, {
222
+ castrum_crc32: { args: inputAbi, returns: 'u32' },
223
+ castrum_fnv1a64: { args: inputAbi, returns: 'u64' },
224
+ castrum_xxh3: { args: inputAbi, returns: 'u64' },
225
+ castrum_json_valid: { args: inputAbi, returns: 'u8' },
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 },
238
+ castrum_hmac_sha256_verify: {
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']),
262
+ returns: 'u8',
263
+ },
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 },
295
+ castrum_base64_encode: {
296
+ args: abi(['ptr', 'usize', 'ptr', 'usize', 'u8', 'u8']),
297
+ returns: U64_FAST,
298
+ },
299
+ castrum_base64_decode: {
300
+ args: abi(['ptr', 'usize', 'ptr', 'usize', 'u8', 'u8']),
301
+ returns: U64_FAST,
302
+ },
303
+ castrum_hmac_sha256: {
304
+ args: abi(['ptr', 'usize', 'ptr', 'usize', 'ptr', 'usize']),
305
+ returns: U64_FAST,
306
+ },
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,
311
+ },
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,
316
+ },
317
+ castrum_csrf_token: { args: abi(['ptr', 'usize']), returns: 'cstring' },
318
+ castrum_csrf_token_into: { args: abi(['ptr', 'usize', 'ptr', 'usize']), returns: U64_FAST },
319
+ castrum_password_hash: {
320
+ args: abi(['ptr', 'usize', 'ptr', 'usize', 'u32', 'u32', 'u32', 'u32', 'ptr', 'usize']),
321
+ returns: U64_FAST,
322
+ },
323
+ castrum_password_hash_bcrypt: {
324
+ args: abi(['ptr', 'usize', 'u32', 'ptr', 'usize']),
325
+ returns: U64_FAST,
326
+ },
327
+ castrum_pbkdf2_sha256: {
328
+ args: abi(['ptr', 'usize', 'ptr', 'usize', 'u32', 'u32', 'ptr', 'usize']),
329
+ returns: U64_FAST,
330
+ },
331
+ castrum_aead_encrypt: {
332
+ args: abi(['ptr', 'usize', 'ptr', 'usize', 'ptr', 'usize', 'u8', 'ptr', 'usize']),
333
+ returns: U64_FAST,
334
+ },
335
+ castrum_aead_decrypt: {
336
+ args: abi(['ptr', 'usize', 'ptr', 'usize', 'ptr', 'usize', 'u8', 'ptr', 'usize']),
337
+ returns: U64_FAST,
338
+ },
339
+ castrum_ws_frame_encode: {
340
+ args: abi(['u8', 'ptr', 'usize', 'u8', 'u8', 'ptr', 'usize']),
341
+ returns: U64_FAST,
342
+ },
343
+ castrum_json_patch: {
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,
350
+ },
351
+ castrum_gzip_decompress: {
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,
358
+ },
359
+ castrum_brotli_decompress: {
360
+ args: abi(['ptr', 'usize', 'usize', 'ptr', 'usize']),
361
+ returns: U64_FAST,
362
+ },
363
+ castrum_gzip_isize: { args: abi(['ptr', 'usize']), returns: 'u32' },
364
+ castrum_http_parse_request_packed: {
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,
380
+ },
381
+ // ── Excluded-surface additions ──
382
+ castrum_jwt_sign_bytes: {
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,
389
+ },
390
+ castrum_ws_frame_decode_packed: {
391
+ args: abi(['ptr', 'usize', 'ptr', 'usize']),
392
+ returns: U64_FAST,
393
+ },
394
+ castrum_multipart_parse_packed: {
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',
431
+ },
432
+ castrum_ingress_handle_packed: {
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,
456
+ },
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' },
466
+ })
467
+
468
+ const bindings = build(symbols as Record<string, (...a: unknown[]) => unknown>, useBufferLength)
469
+ if (!selfTest(bindings)) {
470
+ if (mode === 'ffi') {
471
+ throw new Error(
472
+ 'CASTRUM_FFI_MODE=ffi: the bun:ffi bind-time self-test failed, so the ' +
473
+ 'primary transport cannot be trusted on this Bun/addon combination. ' +
474
+ 'Unset CASTRUM_FFI_MODE (or use CASTRUM_FFI_MODE=auto) to fall back to napi.',
475
+ )
476
+ }
477
+ return null
478
+ }
479
+ return bindings
480
+ } catch (err) {
481
+ if (mode === 'ffi') {
482
+ const cause = err instanceof Error ? `: ${err.message}` : ''
483
+ throw new Error(`CASTRUM_FFI_MODE=ffi: failed to bind bun:ffi${cause}`)
484
+ }
485
+ return null
486
+ }
487
+ }
488
+
489
+ // Types for the raw dlopen'd functions (bun:ffi converts Uint8Array -> ptr for
490
+ // `ptr` args; `usize`/`u64`/`i64` returns surface as BigInt).
491
+
492
+ const flag = (v?: boolean): number => (v ? 1 : 0)
493
+
494
+ /**
495
+ * Write with the C ABI's "needed" convention. The C side returns the EXACT
496
+ * total bytes the result requires: `0` = real error (throw immediately — no
497
+ * re-run, no grow-to-max allocation storm on invalid input), `w > out.length`
498
+ * = buffer too small with `w` the exact required size (allocate once, retry),
499
+ * else `w` is the written count. A miss therefore costs at most ONE retry with
500
+ * an exact buffer — never a doubling loop that re-runs the whole native op
501
+ * (which is what made FFI gzip/brotli/jsonPatch slower than napi).
502
+ */
503
+ function growExact(
504
+ write: (out: Uint8Array) => number,
505
+ initial: number,
506
+ max: number,
507
+ error: string,
508
+ ): Uint8Array {
509
+ let cap = Math.min(Math.max(initial, 16), max)
510
+ for (;;) {
511
+ const out = new Uint8Array(cap)
512
+ const w = Number(write(out))
513
+ if (w === 0) throw new Error(error)
514
+ if (w <= out.length) return out.subarray(0, w)
515
+ if (w > max) throw new Error(error)
516
+ cap = Math.min(w, max)
517
+ }
518
+ }
519
+
520
+ /** Decoder parity: a `0` write on non-empty input throws (napi `Result`). */
521
+ function writeOrThrow(w: number, inputLen: number, label: string): number {
522
+ if (w === 0 && inputLen !== 0) {
523
+ throw new Error(`${label}: invalid input or output buffer too small`)
524
+ }
525
+ return w
526
+ }
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
+
544
+ /** base64 (no-pad) length of `n` bytes. */
545
+ const b64Len = (n: number): number => (n === 0 ? 0 : Math.ceil(n / 3) * 4 - ((3 - (n % 3)) % 3))
546
+
547
+ /**
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.
557
+ */
558
+ function argon2PhcLength(m: number, t: number, p: number, saltLen: number, outLen: number): number {
559
+ return (
560
+ 15 + // "$argon2id$v=19$"
561
+ 2 + // "m="
562
+ String(m).length +
563
+ 3 + // ",t="
564
+ String(t).length +
565
+ 3 + // ",p="
566
+ String(p).length +
567
+ 1 + // "$" before the salt
568
+ b64Len(saltLen) + // salt b64 (no-pad — depends on the caller's salt length)
569
+ 1 + // "$" before the hash
570
+ b64Len(outLen)
571
+ )
572
+ }
573
+
574
+ function build(
575
+ sym: Record<string, (...a: unknown[]) => unknown>,
576
+ useBufferLength: boolean,
577
+ ): BunFFI {
578
+ const crc32 = sym.castrum_crc32 as (...a: unknown[]) => number | bigint
579
+ const fnv = sym.castrum_fnv1a64 as (...a: unknown[]) => number | bigint
580
+ const xxh = sym.castrum_xxh3 as (...a: unknown[]) => number | bigint
581
+ const jsonValid = sym.castrum_json_valid as (...a: unknown[]) => number | bigint
582
+ const utf8Valid = sym.castrum_utf8_valid as (...a: unknown[]) => number | bigint
583
+ const hexEncode = sym.castrum_hex_encode as Raw4
584
+ const hexDecode = sym.castrum_hex_decode as Raw4
585
+ const urlEncode = sym.castrum_url_encode as Raw4
586
+ const urlDecode = sym.castrum_url_decode as Raw4
587
+ const validateEmail = sym.castrum_validate_email as (...a: unknown[]) => number | bigint
588
+ const validateUuid = sym.castrum_validate_uuid as (...a: unknown[]) => number | bigint
589
+ const validateIpv4 = sym.castrum_validate_ipv4 as (...a: unknown[]) => number | bigint
590
+ const validateIpv6 = sym.castrum_validate_ipv6 as (...a: unknown[]) => number | bigint
591
+ const jsonSumRaw = sym.castrum_json_sum_ids as Raw4
592
+
593
+ // Single-buffer input fns: with the probe-gated `buffer/buffer_length` ABI the
594
+ // engine reads ptr + byteLength off the SAME TypedArray at call time (an
595
+ // atomic snapshot — pass the view twice, per Bun's docs); with `(ptr,len)` we
596
+ // pass the length explicitly. The branch is on a bind-time constant, so the
597
+ // JIT folds it away at the call site.
598
+ const oneArg = (raw: (...a: unknown[]) => number | bigint, v: Uint8Array): number | bigint =>
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)
605
+ const hmacVerify = sym.castrum_hmac_sha256_verify as Raw6
606
+ const csrfVerify = sym.castrum_csrf_verify as Raw4
607
+ const passwordVerify = sym.castrum_password_verify as Raw4
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
624
+ const base64Encode = sym.castrum_base64_encode as Raw6
625
+ const base64Decode = sym.castrum_base64_decode as Raw6
626
+ const hmacSha256 = sym.castrum_hmac_sha256 as Raw6
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
633
+ const passwordHash = sym.castrum_password_hash as Raw10
634
+ const passwordHashBcrypt = sym.castrum_password_hash_bcrypt as Raw5
635
+ const pbkdf2 = sym.castrum_pbkdf2_sha256 as Raw8
636
+ const aeadEncrypt = sym.castrum_aead_encrypt as Raw9
637
+ const aeadDecrypt = sym.castrum_aead_decrypt as Raw9
638
+ const wsFrameEncode = sym.castrum_ws_frame_encode as Raw7
639
+ const jsonPatch = sym.castrum_json_patch as Raw6
640
+ const gzipCompress = sym.castrum_gzip_compress as Raw5
641
+ const gzipDecompress = sym.castrum_gzip_decompress as Raw5
642
+ const brotliCompress = sym.castrum_brotli_compress as Raw5
643
+ const brotliDecompress = sym.castrum_brotli_decompress as Raw5
644
+ const gzipIsize = sym.castrum_gzip_isize as Raw2
645
+ const httpParsePacked = sym.castrum_http_parse_request_packed as Raw4
646
+ const queryParsePacked = sym.castrum_query_parse_packed as Raw4
647
+ const cookieParsePacked = sym.castrum_cookie_parse_packed as Raw4
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
653
+ const wsFrameDecodePacked = sym.castrum_ws_frame_decode_packed as Raw4
654
+ const multipartParsePacked = sym.castrum_multipart_parse_packed as Raw6
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
665
+ const ingressHandlePacked = sym.castrum_ingress_handle_packed as Raw7
666
+ const ingressHandleComponentsSym = sym.castrum_ingress_handle_components as Raw12
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
671
+
672
+ // ── Encoder / decoder `Into` helpers ──────────────────────────────
673
+ const hexEncodeInto = (input: Uint8Array, output: Uint8Array): number => {
674
+ // Mirror napi error semantics: a too-small buffer throws, not returns 0.
675
+ // Hex encode always writes exactly `input.length * 2` bytes on success.
676
+ if (output.length < input.length * 2) {
677
+ throw new Error('hex encode: output buffer too small')
678
+ }
679
+ return Number(hexEncode(input, lenOrView(input), output, lenOrView(output)))
680
+ }
681
+
682
+ const urlEncodeInto = (input: Uint8Array, output: Uint8Array): number => {
683
+ const w = Number(urlEncode(input, lenOrView(input), output, lenOrView(output)))
684
+ // Every input byte encodes to >= 1 output byte, so a 0 write on non-empty
685
+ // input means the buffer was too small (napi throws there too). Empty
686
+ // input legitimately writes 0.
687
+ if (w === 0 && input.length !== 0) {
688
+ throw new Error('url encode: output buffer too small')
689
+ }
690
+ return w
691
+ }
692
+
693
+ const hexDecodeInto = (input: Uint8Array, output: Uint8Array): number => {
694
+ const w = Number(hexDecode(input, lenOrView(input), output, lenOrView(output)))
695
+ return writeOrThrow(w, input.length, 'hex decode')
696
+ }
697
+
698
+ const urlDecodeInto = (input: Uint8Array, output: Uint8Array): number => {
699
+ const w = Number(urlDecode(input, lenOrView(input), output, lenOrView(output)))
700
+ return writeOrThrow(w, input.length, 'url decode')
701
+ }
702
+
703
+ const base64DecodeInto = (
704
+ input: Uint8Array,
705
+ output: Uint8Array,
706
+ urlSafe?: boolean,
707
+ padding?: boolean,
708
+ ): number => {
709
+ // napi defaults: urlSafe=false, padding=true (rust/crypto/base64.rs).
710
+ const w = Number(
711
+ base64Decode(
712
+ input,
713
+ lenOrView(input),
714
+ output,
715
+ lenOrView(output),
716
+ flag(urlSafe),
717
+ flag(padding ?? true),
718
+ ),
719
+ )
720
+ return writeOrThrow(w, input.length, 'base64 decode')
721
+ }
722
+
723
+ const base64EncodeInto = (
724
+ input: Uint8Array,
725
+ output: Uint8Array,
726
+ urlSafe?: boolean,
727
+ padding?: boolean,
728
+ ): number => {
729
+ // napi defaults: urlSafe=false, padding=true.
730
+ const w = Number(
731
+ base64Encode(
732
+ input,
733
+ lenOrView(input),
734
+ output,
735
+ lenOrView(output),
736
+ flag(urlSafe),
737
+ flag(padding ?? true),
738
+ ),
739
+ )
740
+ // Empty input legitimately writes 0 bytes — only a 0 write on NON-empty
741
+ // input is a real error (same convention as the decode/etag paths).
742
+ return writeOrThrow(w, input.length, 'base64 encode')
743
+ }
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
+
753
+ const etagInto = (data: Uint8Array, output: Uint8Array, weak?: boolean): number => {
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) {
762
+ throw new Error('etag: output buffer too small')
763
+ }
764
+ return w
765
+ }
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
+
796
+ return {
797
+ crc32: (input) => Number(oneArg(crc32, input)) >>> 0,
798
+ fnv1a64: (input) => BigInt(oneArg(fnv, input)),
799
+ xxh3: (input) => BigInt(oneArg(xxh, input)),
800
+ jsonValid: (input) => Number(oneArg(jsonValid, input)) === 1,
801
+ utf8Valid: (input) => Number(oneArg(utf8Valid, input)) === 1,
802
+ hexEncode(input) {
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)
808
+ const w = hexEncodeInto(input, out)
809
+ return decodeUtf8(out.subarray(0, w))
810
+ },
811
+ hexEncodeInto,
812
+ urlEncode(input) {
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)
816
+ const w = urlEncodeInto(input, out)
817
+ return decodeUtf8(out.subarray(0, w))
818
+ },
819
+ urlEncodeInto,
820
+
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,
825
+ jsonSumIds: (input) => {
826
+ // Packed [u8 ok][i64 sum LE] output (9 B): ok=1 → valid array (the sum
827
+ // may be 0); ok=0 → invalid input. Bytes written: 9/1/0 (0 = real error).
828
+ const w = Number(jsonSumRaw(input, lenOrView(input), jsonSumOut, lenOrView(jsonSumOut)))
829
+ if (w === 0) {
830
+ throw new Error('json sum ids: output buffer too small')
831
+ }
832
+ if (jsonSumOut[0] === 0) {
833
+ // Mirrors the napi error phrasing (serde: "expected an array of objects
834
+ // with numeric ids") so both transports throw the same message.
835
+ throw new Error('json sum ids: expected an array of objects with numeric ids')
836
+ }
837
+ return jsonSumView.getBigInt64(1, true)
838
+ },
839
+ hmacSha256Verify: (key, data, signature) =>
840
+ Number(
841
+ hmacVerify(key, lenOrView(key), data, lenOrView(data), signature, lenOrView(signature)),
842
+ ) === 1,
843
+ csrfVerify: (token, secret) =>
844
+ Number(csrfVerify(token, lenOrView(token), secret, lenOrView(secret))) === 1,
845
+ passwordVerify: (password, phc) =>
846
+ Number(passwordVerify(password, lenOrView(password), phc, lenOrView(phc))) === 1,
847
+ passwordVerifyBcrypt: (password, phc) =>
848
+ Number(passwordVerifyBcrypt(password, lenOrView(password), phc)) === 1,
849
+
850
+ hexDecode(input) {
851
+ const out = new Uint8Array(Math.floor(input.length / 2))
852
+ const w = hexDecodeInto(input, out)
853
+ return out.subarray(0, w)
854
+ },
855
+ hexDecodeInto,
856
+ urlDecode(input) {
857
+ const out = new Uint8Array(input.length)
858
+ const w = urlDecodeInto(input, out)
859
+ return out.subarray(0, w)
860
+ },
861
+ urlDecodeInto,
862
+ base64Decode(input, urlSafe, padding) {
863
+ const out = new Uint8Array(Math.ceil((input.length * 3) / 4))
864
+ const w = base64DecodeInto(input, out, urlSafe, padding)
865
+ return out.subarray(0, w)
866
+ },
867
+ base64DecodeInto,
868
+
869
+ wsAcceptKey(key) {
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)))
876
+ if (w === 0) {
877
+ throw new Error('ws accept key: bad key')
878
+ }
879
+ if (w > output.length) {
880
+ throw new Error('ws accept key: output buffer too small')
881
+ }
882
+ return w
883
+ },
884
+ etag(data, weak) {
885
+ // cstring return (10 strong / 12 weak chars) — zero encode.
886
+ return cstr(etagCStr(data, lenOrView(data), flag(weak)), 'etag: invalid input')
887
+ },
888
+ etagInto,
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)
951
+ }
952
+ return out.subarray(0, w)
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
+ },
1013
+ base64Encode(input, urlSafe, padding) {
1014
+ // Pooled scratch — decoded synchronously to an immutable string (safe).
1015
+ const out = scratchFor(Math.ceil(input.length / 3) * 4)
1016
+ const w = base64EncodeInto(input, out, urlSafe, padding)
1017
+ return decodeUtf8(out.subarray(0, w))
1018
+ },
1019
+ base64EncodeInto,
1020
+ hmacSha256(key, data) {
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)))
1024
+ if (w === 0) {
1025
+ throw new Error('hmac sha256: output buffer too small')
1026
+ }
1027
+ return decodeUtf8(out.subarray(0, w))
1028
+ },
1029
+ hmacSha256Into(key, data, output) {
1030
+ if (output.length < 64) {
1031
+ throw new Error('hmac sha256: output buffer too small')
1032
+ }
1033
+ const w = Number(
1034
+ hmacSha256(key, lenOrView(key), data, lenOrView(data), output, lenOrView(output)),
1035
+ )
1036
+ if (w === 0) {
1037
+ throw new Error('hmac sha256: output buffer too small')
1038
+ }
1039
+ return w
1040
+ },
1041
+ signCookie(value, secret) {
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
+ )
1047
+ },
1048
+ signCookieInto(value, secret, output) {
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.
1052
+ const w = Number(
1053
+ signCookieInto(
1054
+ value,
1055
+ lenOrView(value),
1056
+ secret,
1057
+ lenOrView(secret),
1058
+ output,
1059
+ lenOrView(output),
1060
+ ),
1061
+ )
1062
+ if (w === 0) {
1063
+ throw new Error('sign cookie: invalid input')
1064
+ }
1065
+ if (w > output.length) {
1066
+ throw new Error('sign cookie: output buffer too small')
1067
+ }
1068
+ return w
1069
+ },
1070
+ verifyCookie(signed, secret) {
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
1100
+ },
1101
+ csrfToken(secret) {
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)))
1112
+ if (w === 0) {
1113
+ throw new Error('csrf token: random source failed')
1114
+ }
1115
+ if (w > output.length) {
1116
+ throw new Error('csrf token: output buffer too small')
1117
+ }
1118
+ return w
1119
+ },
1120
+ passwordHash(password, salt, mCost, tCost, pCost, outLen) {
1121
+ // Pre-size with the EXACT PHC string length (computable from the params),
1122
+ // so the hash runs once — a grow-retry would re-run the whole argon2
1123
+ // hash on a miss. growExact remains the safety net.
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
+ ),
1140
+ ),
1141
+ argon2PhcLength(mCost, tCost, pCost, salt.length, outLen),
1142
+ 2 * 1024 * 1024,
1143
+ 'password hash: output buffer too small',
1144
+ ),
1145
+ )
1146
+ },
1147
+ passwordHashBcrypt(password, cost) {
1148
+ // `$2b$CC$` + 22 salt chars + 31 hash chars = 60 chars.
1149
+ const out = new Uint8Array(64)
1150
+ const w = Number(passwordHashBcrypt(password, lenOrView(password), cost, out, lenOrView(out)))
1151
+ if (w === 0) {
1152
+ throw new Error('password hash bcrypt: output buffer too small')
1153
+ }
1154
+ return decodeUtf8(out.subarray(0, w))
1155
+ },
1156
+ pbkdf2Sha256(password, salt, rounds, dkLen) {
1157
+ // Rust clamps dkLen to [1, 1MiB] (PBKDF2_MIN_LEN/MAX_LEN) AFTER sizing its
1158
+ // own buffer — so pre-clamp here so dkLen 0 still yields a 1-byte result.
1159
+ const dk = Math.min(Math.max(dkLen, 1), 1024 * 1024)
1160
+ const out = new Uint8Array(dk)
1161
+ const w = Number(
1162
+ pbkdf2(
1163
+ password,
1164
+ lenOrView(password),
1165
+ salt,
1166
+ lenOrView(salt),
1167
+ rounds,
1168
+ dkLen,
1169
+ out,
1170
+ lenOrView(out),
1171
+ ),
1172
+ )
1173
+ if (w === 0) {
1174
+ throw new Error('pbkdf2: output buffer too small')
1175
+ }
1176
+ return out.subarray(0, w)
1177
+ },
1178
+ aeadEncrypt(key, nonce, plaintext, algorithm = 0) {
1179
+ // ciphertext + 16-byte auth tag.
1180
+ const out = new Uint8Array(plaintext.length + 16)
1181
+ const w = Number(
1182
+ aeadEncrypt(
1183
+ key,
1184
+ lenOrView(key),
1185
+ nonce,
1186
+ lenOrView(nonce),
1187
+ plaintext,
1188
+ lenOrView(plaintext),
1189
+ algorithm,
1190
+ out,
1191
+ lenOrView(out),
1192
+ ),
1193
+ )
1194
+ if (w === 0) {
1195
+ throw new Error('aead encrypt: output buffer too small or bad parameters')
1196
+ }
1197
+ return out.subarray(0, w)
1198
+ },
1199
+ aeadEncryptInto(key, nonce, plaintext, output, algorithm = 0) {
1200
+ const need = plaintext.length + 16
1201
+ if (output.length < need) {
1202
+ throw new Error('aead encrypt: output buffer too small')
1203
+ }
1204
+ const w = Number(
1205
+ aeadEncrypt(
1206
+ key,
1207
+ lenOrView(key),
1208
+ nonce,
1209
+ lenOrView(nonce),
1210
+ plaintext,
1211
+ lenOrView(plaintext),
1212
+ algorithm,
1213
+ output,
1214
+ lenOrView(output),
1215
+ ),
1216
+ )
1217
+ if (w === 0) {
1218
+ throw new Error('aead encrypt: output buffer too small or bad parameters')
1219
+ }
1220
+ return w
1221
+ },
1222
+ aeadDecrypt(key, nonce, ciphertext, algorithm = 0) {
1223
+ const out = new Uint8Array(ciphertext.length)
1224
+ const w = Number(
1225
+ aeadDecrypt(
1226
+ key,
1227
+ lenOrView(key),
1228
+ nonce,
1229
+ lenOrView(nonce),
1230
+ ciphertext,
1231
+ lenOrView(ciphertext),
1232
+ algorithm,
1233
+ out,
1234
+ lenOrView(out),
1235
+ ),
1236
+ )
1237
+ return w === 0 ? null : out.subarray(0, w)
1238
+ },
1239
+
1240
+ wsFrameEncode(opcode, payload, mask, fin) {
1241
+ // Header (max 10) + payload + mask key (4).
1242
+ const out = new Uint8Array(payload.length + 14)
1243
+ const w = Number(
1244
+ wsFrameEncode(
1245
+ opcode,
1246
+ payload,
1247
+ lenOrView(payload),
1248
+ flag(mask),
1249
+ flag(fin),
1250
+ out,
1251
+ lenOrView(out),
1252
+ ),
1253
+ )
1254
+ if (w === 0) {
1255
+ throw new Error('ws frame encode: output buffer too small')
1256
+ }
1257
+ return out.subarray(0, w)
1258
+ },
1259
+ wsFrameEncodeInto(opcode, payload, mask, fin, output) {
1260
+ // Header (max 10) + payload + mask key (4).
1261
+ const need = payload.length + 14
1262
+ if (output.length < need) {
1263
+ throw new Error('ws frame encode: output buffer too small')
1264
+ }
1265
+ const w = Number(
1266
+ wsFrameEncode(
1267
+ opcode,
1268
+ payload,
1269
+ lenOrView(payload),
1270
+ flag(mask),
1271
+ flag(fin),
1272
+ output,
1273
+ lenOrView(output),
1274
+ ),
1275
+ )
1276
+ if (w === 0) {
1277
+ throw new Error('ws frame encode: output buffer too small')
1278
+ }
1279
+ return w
1280
+ },
1281
+ jsonPatch(doc, patch) {
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
+ ),
1290
+ )
1291
+ },
1292
+ gzipCompress(data, level = 6) {
1293
+ // The C ABI streams the compressed output directly into `out` (no internal
1294
+ // Vec — see rust/payload/compress.rs gzip_compress_into). Cap the initial
1295
+ // so large compressible inputs don't pay a full-size allocation (measured:
1296
+ // a 75 KiB input compressed to <1 KiB — COMPRESS_INITIAL_CAP is plenty and
1297
+ // single-pass). growExact handles incompressible data with at most one
1298
+ // exact retry (no re-run loop).
1299
+ return growExact(
1300
+ (out) =>
1301
+ Number(gzipCompress(data, lenOrView(data), Math.min(level, 9), out, lenOrView(out))),
1302
+ Math.min(data.length + COMPRESS_HEADROOM, COMPRESS_INITIAL_CAP),
1303
+ Math.max(data.length * 2 + COMPRESS_HEADROOM, COMPRESS_MAX_CAP),
1304
+ 'gzip compress: output buffer too small',
1305
+ )
1306
+ },
1307
+ gzipCompressInto(data, output, level = 6) {
1308
+ const w = Number(
1309
+ gzipCompress(data, lenOrView(data), Math.min(level, 9), output, lenOrView(output)),
1310
+ )
1311
+ // Needed-size convention: w > output.length = too small; w === 0 = real error.
1312
+ if (w === 0) {
1313
+ throw new Error('gzip compress: invalid input')
1314
+ }
1315
+ if (w > output.length) {
1316
+ throw new Error('gzip compress: output buffer too small')
1317
+ }
1318
+ return w
1319
+ },
1320
+ gzipDecompress(data, maxDecompressed) {
1321
+ const max = maxDecompressed ?? MAX_DECOMPRESSED
1322
+ // Pre-size from the gzip ISIZE trailer via the C probe (exact for
1323
+ // single-member streams) so the happy path is a single native pass; fall
1324
+ // back to a multiplier guess. growExact handles any residual miss with
1325
+ // one exact retry, and invalid input throws immediately (no 64 MiB
1326
+ // grow-to-max allocation storm).
1327
+ const isize = Number(gzipIsize(data, lenOrView(data)))
1328
+ const initial =
1329
+ isize !== 0
1330
+ ? Math.min(isize, max)
1331
+ : Math.min(data.length * DECOMPRESS_GUESS_MULTIPLIER_GZIP, DECOMPRESS_FALLBACK_CAP)
1332
+ return growExact(
1333
+ (out) => Number(gzipDecompress(data, lenOrView(data), max, out, lenOrView(out))),
1334
+ initial,
1335
+ max,
1336
+ 'gzip decompress: invalid stream or exceeded max decompressed size',
1337
+ )
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
+ },
1355
+ brotliCompress(data, quality = 5) {
1356
+ // Same cap rationale as gzipCompress (streaming core); growExact for
1357
+ // incompressible data.
1358
+ return growExact(
1359
+ (out) =>
1360
+ Number(brotliCompress(data, lenOrView(data), Math.min(quality, 11), out, lenOrView(out))),
1361
+ Math.min(data.length + COMPRESS_HEADROOM, COMPRESS_INITIAL_CAP),
1362
+ Math.max(data.length * 2 + COMPRESS_HEADROOM, COMPRESS_MAX_CAP),
1363
+ 'brotli compress: output buffer too small',
1364
+ )
1365
+ },
1366
+ brotliCompressInto(data, output, quality = 5) {
1367
+ const w = Number(
1368
+ brotliCompress(data, lenOrView(data), Math.min(quality, 11), output, lenOrView(output)),
1369
+ )
1370
+ // Needed-size convention: w > output.length = too small; w === 0 = real error.
1371
+ if (w === 0) {
1372
+ throw new Error('brotli compress: invalid input')
1373
+ }
1374
+ if (w > output.length) {
1375
+ throw new Error('brotli compress: output buffer too small')
1376
+ }
1377
+ return w
1378
+ },
1379
+ brotliDecompress(data, maxDecompressed) {
1380
+ const max = maxDecompressed ?? MAX_DECOMPRESSED
1381
+ // Brotli has no cheap trailer size: DECOMPRESS_GUESS_MULTIPLIER_BROTLI×
1382
+ // initial covers typical JSON/text ratios (~10-30x) while the fallback
1383
+ // cap bounds over-allocation on large streams; growExact handles
1384
+ // higher-ratio streams with one exact retry.
1385
+ return growExact(
1386
+ (out) => Number(brotliDecompress(data, lenOrView(data), max, out, lenOrView(out))),
1387
+ Math.min(
1388
+ Math.max(data.length * DECOMPRESS_GUESS_MULTIPLIER_BROTLI, DECOMPRESS_MIN_INITIAL),
1389
+ DECOMPRESS_FALLBACK_CAP,
1390
+ ),
1391
+ max,
1392
+ 'brotli decompress: invalid stream or exceeded max decompressed size',
1393
+ )
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
+ },
1408
+
1409
+ httpParseRequestPackedInto(input, output) {
1410
+ const w = Number(httpParsePacked(input, lenOrView(input), output, lenOrView(output)))
1411
+ if (w === 0 && input.length !== 0) {
1412
+ throw new Error('http parse: output buffer too small or malformed request')
1413
+ }
1414
+ return w
1415
+ },
1416
+ queryParsePackedInto(input, output) {
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) {
1422
+ throw new Error('query parse: output buffer too small')
1423
+ }
1424
+ return w
1425
+ },
1426
+ cookieParsePackedInto(input, output) {
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) {
1430
+ throw new Error('cookie parse: output buffer too small')
1431
+ }
1432
+ return w
1433
+ },
1434
+
1435
+ // ── Excluded-surface additions ──────────────────────────────────
1436
+ jwtSignBytes(claimsJson, secret, ttl, now) {
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),
1467
+ ),
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
1476
+ },
1477
+ wsFrameDecodePacked(data) {
1478
+ // Max packed output = 6-byte header + payload.
1479
+ const out = new Uint8Array(data.length + 6)
1480
+ const w = Number(wsFrameDecodePacked(data, lenOrView(data), out, lenOrView(out)))
1481
+ return w === 0 ? null : out.subarray(0, w)
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
+ },
1492
+ multipartParsePacked(body, boundary) {
1493
+ return growExact(
1494
+ (out) =>
1495
+ Number(
1496
+ multipartParsePacked(
1497
+ body,
1498
+ lenOrView(body),
1499
+ boundary,
1500
+ lenOrView(boundary),
1501
+ out,
1502
+ lenOrView(out),
1503
+ ),
1504
+ ),
1505
+ Math.min(body.length + boundary.length + 64, 64 * 1024),
1506
+ 128 * 1024 * 1024,
1507
+ 'multipart parse: output buffer too small',
1508
+ )
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
+ },
1529
+ formParsePackedInto(input, output) {
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) {
1533
+ throw new Error('form parse: output buffer too small')
1534
+ }
1535
+ return w
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
+
1701
+ ingressHandlePacked(inner, input, body, output) {
1702
+ const w = Number(
1703
+ ingressHandlePacked(
1704
+ inner,
1705
+ input,
1706
+ lenOrView(input),
1707
+ body ?? EMPTY_VIEW,
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),
1712
+ output,
1713
+ lenOrView(output),
1714
+ ),
1715
+ )
1716
+ if (w === 0) {
1717
+ throw new Error('ingress handle: output buffer too small or pipeline error')
1718
+ }
1719
+ return w
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
+ },
1746
+ ingressLayout(out) {
1747
+ const w = Number(ingressLayoutSym(out, lenOrView(out)))
1748
+ if (w !== out.length) {
1749
+ throw new Error('ingress layout: output buffer too small')
1750
+ }
1751
+ return w
1752
+ },
1753
+
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
+ },
1774
+ }
1775
+ }