castrum 0.9.0 → 0.9.2

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 (120) hide show
  1. package/README.md +153 -630
  2. package/castrum.linux-x64-gnu.node +0 -0
  3. package/dist/index.d.ts +1653 -1817
  4. package/dist/index.js +6172 -4294
  5. package/docs/API.md +337 -0
  6. package/docs/ARCHITECTURE.md +111 -17
  7. package/docs/BENCHMARKS.md +117 -49
  8. package/docs/CASE_STUDY.md +243 -0
  9. package/docs/ENVIRONMENT.md +24 -7
  10. package/docs/FFI_BUN_GUIDE.md +335 -0
  11. package/docs/GETTING_STARTED.md +18 -34
  12. package/docs/HOW_TO_ADD_AN_OP.md +180 -0
  13. package/docs/INGRESS-ROUTER.md +82 -0
  14. package/docs/INGRESS.md +11 -7
  15. package/docs/NATIVE-ROUTE.md +77 -0
  16. package/docs/REPO_MAP.md +103 -53
  17. package/docs/adr/0001-two-wire-formats.md +1 -1
  18. package/docs/adr/0003-bun-builtins-delegation.md +6 -5
  19. package/docs/ai/LOCAL_DEV.md +81 -0
  20. package/docs/ai/TREE.md +1070 -0
  21. package/docs/bun-builtins-decision-matrix.md +25 -18
  22. package/index.ts +9 -16
  23. package/package.json +41 -28
  24. package/src/ingress/body.ts +167 -27
  25. package/src/ingress/decode/result-base.ts +10 -2
  26. package/src/ingress/handlers.ts +116 -285
  27. package/src/ingress/headers/security.ts +41 -0
  28. package/src/ingress/health.ts +1 -0
  29. package/src/ingress/index.ts +45 -169
  30. package/src/ingress/native-route.ts +182 -0
  31. package/src/ingress/packing/gather-raw-headers.ts +110 -22
  32. package/src/ingress/packing/header-packing.ts +5 -5
  33. package/src/ingress/packing/input-packer.ts +17 -3
  34. package/src/ingress/packing/route-wire.ts +197 -0
  35. package/src/ingress/packing/select-headers.ts +20 -7
  36. package/src/ingress/path-matcher.ts +115 -0
  37. package/src/ingress/response/baked-response.ts +302 -0
  38. package/src/ingress/router.ts +277 -0
  39. package/src/ingress/routes/common.ts +50 -1
  40. package/src/ingress/routes/fallback.ts +2 -2
  41. package/src/ingress/routes/head.ts +3 -13
  42. package/src/ingress/routes/index.ts +5 -4
  43. package/src/ingress/routes/json-write.ts +26 -47
  44. package/src/ingress/routes/native.ts +130 -0
  45. package/src/ingress/routes/options.ts +2 -12
  46. package/src/ingress/routes/read.ts +3 -13
  47. package/src/ingress/routes/responder.ts +159 -0
  48. package/src/ingress/server-node.ts +7 -7
  49. package/src/ingress/server.ts +83 -112
  50. package/src/ingress/sync.ts +151 -0
  51. package/src/ingress/types.ts +44 -1
  52. package/src/integration/pipeline.ts +3 -0
  53. package/src/integration/streaming.ts +2 -3
  54. package/src/integration/websocket.ts +4 -3
  55. package/src/loader/batch.ts +4 -0
  56. package/src/loader/cost.ts +1 -0
  57. package/src/loader/index.ts +43 -228
  58. package/src/loader/state.ts +84 -0
  59. package/src/loader/types.ts +145 -0
  60. package/src/native/ffi/build/codecs.ts +736 -0
  61. package/src/native/ffi/build/compress.ts +248 -0
  62. package/src/native/ffi/build/instances.ts +598 -0
  63. package/src/native/ffi/build/parse.ts +539 -0
  64. package/src/native/ffi/build/util.ts +140 -0
  65. package/src/native/ffi/build.ts +94 -0
  66. package/src/native/ffi/constants.ts +58 -0
  67. package/src/native/ffi/selftest.ts +22 -0
  68. package/src/native/ffi/types.ts +499 -0
  69. package/src/native/ffi.ts +307 -1357
  70. package/src/native/loader.ts +52 -6
  71. package/src/native/types/instances.ts +279 -0
  72. package/src/native/types.ts +41 -238
  73. package/src/runtime/builtins.ts +131 -0
  74. package/src/runtime/codec.ts +147 -0
  75. package/src/runtime/detect.ts +36 -0
  76. package/src/runtime/index.ts +28 -0
  77. package/src/runtime/native.ts +35 -0
  78. package/src/runtime/server.ts +31 -0
  79. package/src/runtime/transport.ts +64 -0
  80. package/src/runtime/types.ts +173 -0
  81. package/src/runtime/uuid.ts +45 -0
  82. package/src/runtime/websocket.ts +30 -0
  83. package/src/rust-ffi/addon.ts +1 -1
  84. package/src/rust-ffi/batch/build.ts +155 -0
  85. package/src/rust-ffi/batch/index.ts +8 -0
  86. package/src/rust-ffi/batch/types.ts +92 -0
  87. package/src/rust-ffi/client.ts +5 -6
  88. package/src/rust-ffi/context.ts +39 -10
  89. package/src/rust-ffi/index.ts +28 -17
  90. package/src/rust-ffi/into.ts +21 -0
  91. package/src/rust-ffi/options.ts +1 -1
  92. package/src/rust-ffi/packed.ts +40 -2
  93. package/src/rust-ffi/proven.ts +16 -30
  94. package/src/rust-ffi/scalar/crypto.ts +188 -107
  95. package/src/rust-ffi/scalar/factories.ts +257 -27
  96. package/src/rust-ffi/scalar/hashing.ts +43 -41
  97. package/src/rust-ffi/scalar/http.ts +208 -77
  98. package/src/rust-ffi/scalar/index.ts +3 -3
  99. package/src/rust-ffi/scalar/interface.ts +88 -213
  100. package/src/rust-ffi/scalar/json-packed.ts +175 -0
  101. package/src/rust-ffi/scalar/json.ts +28 -12
  102. package/src/rust-ffi/scalar/payload.ts +159 -42
  103. package/src/rust-ffi/text.ts +31 -5
  104. package/src/selection.ts +27 -31
  105. package/src/shared/adaptive.ts +1 -2
  106. package/src/shared/buffer-pool.ts +72 -17
  107. package/src/shared/bytes.ts +75 -6
  108. package/src/shared/codec.ts +78 -0
  109. package/src/shared/env.ts +4 -4
  110. package/src/shared/log.ts +1 -0
  111. package/src/shared/packed/index.ts +10 -0
  112. package/src/shared/packed/parsers.ts +31 -0
  113. package/src/shared/packed/schema.ts +23 -0
  114. package/src/shared/{packed.ts → packed/wire.ts} +31 -39
  115. package/src/shared/proven.ts +189 -496
  116. package/src/shared/runtime.ts +20 -25
  117. package/src/shared/uuid.ts +12 -28
  118. package/src/data/json-rows.ts +0 -33
  119. package/src/rust-ffi/batch.ts +0 -431
  120. package/src/shared/bench-classify.ts +0 -209
@@ -0,0 +1,539 @@
1
+ // src/native/ffi/build/parse.ts — parser / wire-format BunFFI methods.
2
+ //
3
+ // The packed parsers (HTTP request, query, cookie, form, multipart, ws frame
4
+ // decode), the packed structural JSON parser, media-type / http-date /
5
+ // accept-encoding verdicts, MIME lookup, URL resolve, http-date formatting,
6
+ // and SSE encoding. Receives the raw dlopen'd symbols and the per-bind context
7
+ // from `build()`.
8
+
9
+ import { decodeJsonPacked } from '../../../rust-ffi/scalar/json-packed'
10
+ import { decodeUtf8, encodeUtf8 } from '../../../shared/codec'
11
+ import { EMPTY_VIEW, SELFTEST_HEX } from '../constants'
12
+ import type { BunFFI, Raw3, Raw4, Raw6, Raw7, Raw10 } from '../types'
13
+ import type { BuildCtx } from './util'
14
+ import { flag, growExact } from './util'
15
+
16
+ /**
17
+ * Build the parser/wire-format methods of the BunFFI surface. `ctx` is
18
+ * destructured so the method bodies read exactly as the original `build()`.
19
+ */
20
+ export function buildParse(
21
+ sym: Record<string, (...a: unknown[]) => unknown>,
22
+ ctx: BuildCtx,
23
+ ): Partial<BunFFI> {
24
+ const { lenOrView, scratchFor, dateScratch, dateScratchView } = ctx
25
+
26
+ const httpParsePacked = sym.castrum_http_parse_request_packed as Raw4
27
+ const queryParsePacked = sym.castrum_query_parse_packed as Raw4
28
+ const cookieParsePacked = sym.castrum_cookie_parse_packed as Raw4
29
+ const httpDateIntoRaw = sym.castrum_http_date_into as Raw3
30
+ const sseEncodeIntoRaw = sym.castrum_sse_encode_into as Raw10
31
+ const jsonParsePackedSym = sym.castrum_json_parse_packed as Raw4
32
+ const wsFrameDecodePacked = sym.castrum_ws_frame_decode_packed as Raw4
33
+ const multipartParsePacked = sym.castrum_multipart_parse_packed as Raw6
34
+ const formParsePacked = sym.castrum_form_parse_packed as Raw4
35
+ const wsFrameEncode = sym.castrum_ws_frame_encode as Raw7
36
+ const parseMediaTypeSym = sym.castrum_parse_media_type as Raw4
37
+ const parseHttpDateSym = sym.castrum_parse_http_date as Raw4
38
+ const parseAcceptEncodingSym = sym.castrum_parse_accept_encoding as Raw4
39
+ const urlEncodeQuerySym = sym.castrum_url_encode_query as (...a: unknown[]) => string | null
40
+ const urlResolveSym = sym.castrum_url_resolve as (...a: unknown[]) => string | null
41
+ const mimeFromExtensionSym = sym.castrum_mime_from_extension as (...a: unknown[]) => string | null
42
+
43
+ return {
44
+ httpParseRequestPackedInto(input, output) {
45
+ const w = Number(httpParsePacked(input, lenOrView(input), output, lenOrView(output)))
46
+ if (w === 0 && input.length !== 0) {
47
+ throw new Error('http parse: output buffer too small or malformed request')
48
+ }
49
+ return w
50
+ },
51
+ queryParsePackedInto(input, output) {
52
+ const w = Number(queryParsePacked(input, lenOrView(input), output, lenOrView(output)))
53
+ // Needed-size convention: w > output.length = exact required size →
54
+ // too-small (throw — the caller owns the buffer, nothing to grow);
55
+ // w === 0 = real error (malformed %XX).
56
+ if (w === 0 || w > output.length) {
57
+ throw new Error('query parse: output buffer too small')
58
+ }
59
+ return w
60
+ },
61
+ cookieParsePackedInto(input, output) {
62
+ const w = Number(cookieParsePacked(input, lenOrView(input), output, lenOrView(output)))
63
+ // Needed-size convention (same as queryParsePackedInto).
64
+ if (w === 0 || w > output.length) {
65
+ throw new Error('cookie parse: output buffer too small')
66
+ }
67
+ return w
68
+ },
69
+ wsFrameEncode(opcode, payload, mask, fin) {
70
+ // Header (max 10) + payload + mask key (4).
71
+ const out = new Uint8Array(payload.length + 14)
72
+ const w = Number(
73
+ wsFrameEncode(
74
+ opcode,
75
+ payload,
76
+ lenOrView(payload),
77
+ flag(mask),
78
+ flag(fin),
79
+ out,
80
+ lenOrView(out),
81
+ ),
82
+ )
83
+ if (w === 0) {
84
+ throw new Error('ws frame encode: output buffer too small')
85
+ }
86
+ return out.subarray(0, w)
87
+ },
88
+ wsFrameEncodeInto(opcode, payload, mask, fin, output) {
89
+ // Header (max 10) + payload + mask key (4).
90
+ const need = payload.length + 14
91
+ if (output.length < need) {
92
+ throw new Error('ws frame encode: output buffer too small')
93
+ }
94
+ const w = Number(
95
+ wsFrameEncode(
96
+ opcode,
97
+ payload,
98
+ lenOrView(payload),
99
+ flag(mask),
100
+ flag(fin),
101
+ output,
102
+ lenOrView(output),
103
+ ),
104
+ )
105
+ if (w === 0) {
106
+ throw new Error('ws frame encode: output buffer too small')
107
+ }
108
+ return w
109
+ },
110
+ wsFrameDecodePacked(data) {
111
+ // Max packed output = 6-byte header + payload.
112
+ const out = new Uint8Array(data.length + 6)
113
+ const w = Number(wsFrameDecodePacked(data, lenOrView(data), out, lenOrView(out)))
114
+ return w === 0 ? null : out.subarray(0, w)
115
+ },
116
+ wsFrameDecodePackedInto(data, output) {
117
+ // Pooled sibling: the caller provides the output buffer, sized to at
118
+ // least `data.length + 6` (6-byte header + payload — the Rust core
119
+ // returns 0 for BOTH too-small and malformed, so the caller must size
120
+ // to the max; the allocating path always does). 0 = malformed → null,
121
+ // mirroring the allocating path's return contract.
122
+ const w = Number(wsFrameDecodePacked(data, lenOrView(data), output, lenOrView(output)))
123
+ return w === 0 ? null : w
124
+ },
125
+ multipartParsePacked(body, boundary) {
126
+ return growExact(
127
+ (out) =>
128
+ Number(
129
+ multipartParsePacked(
130
+ body,
131
+ lenOrView(body),
132
+ boundary,
133
+ lenOrView(boundary),
134
+ out,
135
+ lenOrView(out),
136
+ ),
137
+ ),
138
+ Math.min(body.length + boundary.length + 64, 64 * 1024),
139
+ 128 * 1024 * 1024,
140
+ 'multipart parse: output buffer too small',
141
+ )
142
+ },
143
+ multipartParsePackedInto(body, boundary, output) {
144
+ // Pooled sibling: the caller provides the output buffer. Needed-size
145
+ // convention (same as queryParsePackedInto): w > output.length = exact
146
+ // required size → throw; w === 0 = real error (malformed).
147
+ const w = Number(
148
+ multipartParsePacked(
149
+ body,
150
+ lenOrView(body),
151
+ boundary,
152
+ lenOrView(boundary),
153
+ output,
154
+ lenOrView(output),
155
+ ),
156
+ )
157
+ if (w === 0 || w > output.length) {
158
+ throw new Error('multipart parse: output buffer too small or malformed body')
159
+ }
160
+ return w
161
+ },
162
+ formParsePackedInto(input, output) {
163
+ const w = Number(formParsePacked(input, lenOrView(input), output, lenOrView(output)))
164
+ // Needed-size convention (form aliases the query core — same convention).
165
+ if (w === 0 || w > output.length) {
166
+ throw new Error('form parse: output buffer too small')
167
+ }
168
+ return w
169
+ },
170
+ jsonParsePacked(input) {
171
+ // Packed token stream (deduped string table + typed value tree) — the
172
+ // scalar wrapper assembles the JS value from the tokens with NO second
173
+ // JSON text parse. `0` = invalid JSON (real error → growExact throws);
174
+ // `w > out.length` = exact required size (one exact retry).
175
+ return growExact(
176
+ (out) => Number(jsonParsePackedSym(input, lenOrView(input), out, lenOrView(out))),
177
+ Math.min(input.length + (input.length >> 1), 16 * 1024 * 1024),
178
+ Math.max(1024 * 1024, input.length * 16),
179
+ 'json parse packed: invalid JSON or output buffer too small',
180
+ )
181
+ },
182
+ parseMediaType(input) {
183
+ // Packed verdict: [u32 mediaTypeLen][mediaType][u32 charsetLen
184
+ // (0xFFFFFFFF = none)][charset][u32 boundaryLen][boundary]
185
+ // [u32 paramCount]{[u32 keyLen][key][u32 valLen][val]}. 0 = invalid media
186
+ // type (real error → growExact throws); w > output.length = exact needed
187
+ // size (one exact retry).
188
+ return growExact(
189
+ (out) => Number(parseMediaTypeSym(input, lenOrView(input), out, lenOrView(out))),
190
+ Math.min(input.length + 64, 64 * 1024),
191
+ 1024 * 1024,
192
+ 'media type parse: invalid media type or output buffer too small',
193
+ )
194
+ },
195
+ parseHttpDate(input) {
196
+ // Packed [u8 ok][i64 secs LE] (9 B) / 1 B (ok=0). A too-small buffer is
197
+ // never a 0 here — the Rust side reports the exact size (9/1) — so a 9-byte
198
+ // buffer always succeeds. Reused scratch + cached DataView (no per-call
199
+ // allocs).
200
+ const out = dateScratch
201
+ const w = Number(parseHttpDateSym(input, lenOrView(input), out, lenOrView(out)))
202
+ if (w === 0) {
203
+ throw new Error('http date parse: output buffer too small')
204
+ }
205
+ if (out[0] === 0) return null
206
+ return dateScratchView.getBigInt64(1, true)
207
+ },
208
+ parseAcceptEncoding(input) {
209
+ // Packed: [u32 count]{[u32 encLen][enc][f32 q][u32 order]} (empty header →
210
+ // count 0, 4 bytes). 0 = too small (real error → growExact throws).
211
+ return growExact(
212
+ (out) => Number(parseAcceptEncodingSym(input, lenOrView(input), out, lenOrView(out))),
213
+ Math.min(input.length + 64, 64 * 1024),
214
+ 1024 * 1024,
215
+ 'accept encoding parse: output buffer too small',
216
+ )
217
+ },
218
+ urlEncodeQuery(input) {
219
+ // Packed pairs `[u32 count]{[u32 keyLen][key][u32 valLen][val]}` (the JS
220
+ // `packPairs` layout) → percent-encoded query TEXT as a cstring, keys
221
+ // SORTED (matches the napi BTreeMap ordering). `null` = malformed packed
222
+ // input / non-UTF-8 (napi parity: throws).
223
+ return urlEncodeQuerySym(input, lenOrView(input))
224
+ },
225
+ urlResolve(base, reference) {
226
+ // RFC 3986 resolution → cstring; `null` = non-UTF-8 input (napi parity).
227
+ return urlResolveSym(base, lenOrView(base), reference, lenOrView(reference))
228
+ },
229
+ mimeFromExtension(ext) {
230
+ // Extension → MIME → cstring (cstring ARG: the engine transcodes the JS
231
+ // extension in-engine); unknown → `application/octet-stream` (never null).
232
+ return mimeFromExtensionSym(ext)
233
+ },
234
+ httpDateInto(secs, output) {
235
+ // 29-byte HTTP-date into the caller buffer. 0 = too-small buffer or
236
+ // out-of-range year (napi httpDateInto throws on both).
237
+ const w = Number(httpDateIntoRaw(secs, output, lenOrView(output)))
238
+ if (w === 0) {
239
+ throw new Error('http date: output buffer too small or year out of range')
240
+ }
241
+ return w
242
+ },
243
+ httpDate(secs) {
244
+ // Allocating sibling — 32-byte buffer always fits the 29-byte date, so
245
+ // the only 0 case is an out-of-range year (napi falls back to the
246
+ // allocating format! there — mirror with Date.toUTCString). Pooled
247
+ // scratch (decoded synchronously — safe).
248
+ const out = scratchFor(32)
249
+ const w = Number(httpDateIntoRaw(secs ?? 0, out, lenOrView(out)))
250
+ if (w !== 0) return decodeUtf8(out.subarray(0, w))
251
+ return new Date((secs ?? 0) * 1000).toUTCString()
252
+ },
253
+ sseEncodeEvent(event, data, id, retry) {
254
+ // One SSE event → bytes. event/id are encoded to UTF-8 only when
255
+ // non-null (flag bits 1/2/4 = present) so a present-but-empty string is
256
+ // distinct from absent (napi Option parity). growExact with the needed-size
257
+ // convention; `data.length + 64` covers the common single-line case.
258
+ const ev = event === null ? EMPTY_VIEW : encodeUtf8(event)
259
+ const idv = id === null ? EMPTY_VIEW : encodeUtf8(id)
260
+ const flags = (event === null ? 0 : 1) | (id === null ? 0 : 2) | (retry === null ? 0 : 4)
261
+ return growExact(
262
+ (out) =>
263
+ Number(
264
+ sseEncodeIntoRaw(
265
+ ev,
266
+ lenOrView(ev),
267
+ data,
268
+ lenOrView(data),
269
+ idv,
270
+ lenOrView(idv),
271
+ flags,
272
+ retry ?? 0,
273
+ out,
274
+ lenOrView(out),
275
+ ),
276
+ ),
277
+ data.length + 64,
278
+ 1 << 20,
279
+ 'sse encode: output buffer too small',
280
+ )
281
+ },
282
+ sseEncodeEventInto(event, data, id, retry, output) {
283
+ // Pooled sibling — caller-owned output buffer (sized ≥ `data.length + 64`
284
+ // for the common single-line case). Needed-size convention: w > output.length
285
+ // = exact required size → throw; w === 0 = real error (invalid UTF-8).
286
+ const ev = event === null ? EMPTY_VIEW : encodeUtf8(event)
287
+ const idv = id === null ? EMPTY_VIEW : encodeUtf8(id)
288
+ const flags = (event === null ? 0 : 1) | (id === null ? 0 : 2) | (retry === null ? 0 : 4)
289
+ const w = Number(
290
+ sseEncodeIntoRaw(
291
+ ev,
292
+ lenOrView(ev),
293
+ data,
294
+ lenOrView(data),
295
+ idv,
296
+ lenOrView(idv),
297
+ flags,
298
+ retry ?? 0,
299
+ output,
300
+ lenOrView(output),
301
+ ),
302
+ )
303
+ if (w === 0) {
304
+ throw new Error('sse encode: invalid UTF-8 in event/id')
305
+ }
306
+ if (w > output.length) {
307
+ throw new Error('sse encode: output buffer too small')
308
+ }
309
+ return w
310
+ },
311
+ }
312
+ }
313
+
314
+ /**
315
+ * Bind-time self-test for the parser/wire-format surface (the methods built
316
+ * in `buildParse`). `false` disables the ffi layer and forces the napi
317
+ * fallback.
318
+ */
319
+ export function selfTestParse(b: BunFFI): boolean {
320
+ const enc = { encode: encodeUtf8 }
321
+ const dec = { decode: decodeUtf8 }
322
+
323
+ // WebSocket frame: text frame, FIN, no mask → first byte 0x81.
324
+ const frame = b.wsFrameEncode(1, SELFTEST_HEX, false, true)
325
+ if (frame.length === 0 || frame[0] !== 0x81) {
326
+ return false
327
+ }
328
+
329
+ // Packed parsers (non-empty output). Packed output is LARGER than input
330
+ // (each component gets a u32 length prefix), so size with the Rust
331
+ // allocator's conservative upper bound (`input.len() * 9 + 16` in
332
+ // query_parser.rs).
333
+ const req = enc.encode('GET /a?b=1 HTTP/1.1\r\nHost: example.com\r\n\r\n')
334
+ const reqOut = new Uint8Array(req.length * 9 + 16)
335
+ if (b.httpParseRequestPackedInto(req, reqOut) === 0) {
336
+ return false
337
+ }
338
+ const qIn = enc.encode('a=1&b=2')
339
+ const qOut = new Uint8Array(qIn.length * 9 + 16)
340
+ if (b.queryParsePackedInto(qIn, qOut) === 0) {
341
+ return false
342
+ }
343
+ const cIn = enc.encode('a=1; b=2')
344
+ const cOut = new Uint8Array(cIn.length * 9 + 16)
345
+ if (b.cookieParsePackedInto(cIn, cOut) === 0) {
346
+ return false
347
+ }
348
+
349
+ // form parse shares the query core → 2 pairs.
350
+ const fIn = enc.encode('a=1&b=2')
351
+ const fOut = new Uint8Array(fIn.length * 9 + 16)
352
+ if (b.formParsePackedInto(fIn, fOut) === 0 || fOut[0] !== 2) {
353
+ return false
354
+ }
355
+
356
+ // multipart parse → 1 part named "field".
357
+ const boundary = enc.encode('----boundary')
358
+ // Wire format is `--{boundary}` — boundary is `----boundary`, so the body
359
+ // must open with `------boundary`.
360
+ const mBody = enc.encode(
361
+ '------boundary\r\nContent-Disposition: form-data; name="field"\r\n\r\nvalue\r\n------boundary--',
362
+ )
363
+ const mOut = b.multipartParsePacked(mBody, boundary)
364
+ if (mOut[0] !== 1) {
365
+ return false
366
+ }
367
+ // Packed layout: [u32 count][u32 name_len][name]... → name_len at offset 4.
368
+ const mNameLen =
369
+ (mOut[4] ?? 0) | ((mOut[5] ?? 0) << 8) | ((mOut[6] ?? 0) << 16) | ((mOut[7] ?? 0) << 24)
370
+ if (dec.decode(mOut.subarray(8, 8 + mNameLen)) !== 'field') {
371
+ return false
372
+ }
373
+
374
+ // WS frame decode: encode("hello") → decode → fin=1, opcode=1, payload="hello".
375
+ const wf = b.wsFrameEncode(1, SELFTEST_HEX, true, true)
376
+ const wd = b.wsFrameDecodePacked(wf)
377
+ if (wd === null || wd[0] !== 1 || wd[1] !== 1 || dec.decode(wd.subarray(6)) !== 'hello') {
378
+ return false
379
+ }
380
+ if (b.wsFrameDecodePacked(enc.encode('\x80')) !== null) {
381
+ return false
382
+ }
383
+ // Pooled sibling: same decode into a caller buffer; malformed → null.
384
+ const wdInto = new Uint8Array(wf.length + 6)
385
+ const wdW = b.wsFrameDecodePackedInto(wf, wdInto)
386
+ if (
387
+ wdW === null ||
388
+ wdInto[0] !== 1 ||
389
+ wdInto[1] !== 1 ||
390
+ dec.decode(wdInto.subarray(6, wdW)) !== 'hello'
391
+ ) {
392
+ return false
393
+ }
394
+ if (b.wsFrameDecodePackedInto(enc.encode('\x80'), wdInto) !== null) {
395
+ return false
396
+ }
397
+
398
+ // Multipart parse Into (pooled): 1 part named "field".
399
+ const mOutInto = new Uint8Array(mBody.length + boundary.length + 64)
400
+ const mW = b.multipartParsePackedInto(mBody, boundary, mOutInto)
401
+ if (mW === 0 || mOutInto[0] !== 1) {
402
+ return false
403
+ }
404
+ const mNameLenInto =
405
+ (mOutInto[4] ?? 0) |
406
+ ((mOutInto[5] ?? 0) << 8) |
407
+ ((mOutInto[6] ?? 0) << 16) |
408
+ ((mOutInto[7] ?? 0) << 24)
409
+ if (dec.decode(mOutInto.subarray(8, 8 + mNameLenInto)) !== 'field') {
410
+ return false
411
+ }
412
+
413
+ // HTTP-date Into: Sun, 06 Nov 1994 08:49:37 GMT (fixed 29 bytes).
414
+ const dateOut = new Uint8Array(32)
415
+ const dateW = b.httpDateInto(784111777, dateOut)
416
+ if (dateW !== 29 || dec.decode(dateOut.subarray(0, dateW)) !== 'Sun, 06 Nov 1994 08:49:37 GMT') {
417
+ return false
418
+ }
419
+
420
+ // SSE encode Into: event/id/retry present + null-omission parity.
421
+ const sseData = enc.encode('hello')
422
+ const sse1 = dec.decode(b.sseEncodeEvent('update', sseData, '42', 3000))
423
+ if (sse1 !== 'id: 42\nevent: update\nretry: 3000\ndata: hello\n\n') {
424
+ return false
425
+ }
426
+ const sse2 = dec.decode(b.sseEncodeEvent(null, sseData, null, null))
427
+ if (sse2 !== 'data: hello\n\n') {
428
+ return false
429
+ }
430
+ // Present-but-empty event string emits the line (Option parity vs napi).
431
+ const sse3 = dec.decode(b.sseEncodeEvent('', sseData, null, null))
432
+ if (sse3 !== 'event: \ndata: hello\n\n') {
433
+ return false
434
+ }
435
+ // Pooled sibling: same bytes into a caller buffer; too-small throws.
436
+ const ssePool = new Uint8Array(128)
437
+ const sseW = b.sseEncodeEventInto('update', sseData, '42', 3000, ssePool)
438
+ if (sseW !== sse1.length || dec.decode(ssePool.subarray(0, sseW)) !== sse1) {
439
+ return false
440
+ }
441
+ try {
442
+ b.sseEncodeEventInto('update', sseData, '42', 3000, new Uint8Array(4))
443
+ return false
444
+ } catch {
445
+ // expected
446
+ }
447
+
448
+ // jsonParsePacked: packed token stream decodes (via the REAL public decoder)
449
+ // to the same value as JSON.parse, with NO second text parse; invalid JSON
450
+ // throws (napi parity). Also verifies the needed-size retry path.
451
+ {
452
+ const packed = b.jsonParsePacked(enc.encode('{"a":1,"b":[true,null,"x"],"n":{"v":2.5}}'))
453
+ const v = decodeJsonPacked(packed) as { a: number; b: unknown[]; n: { v: number } }
454
+ if (v.a !== 1 || v.b[0] !== true || v.b[1] !== null || v.b[2] !== 'x' || v.n.v !== 2.5) {
455
+ return false
456
+ }
457
+ try {
458
+ b.jsonParsePacked(enc.encode('nope'))
459
+ return false
460
+ } catch {
461
+ // expected: invalid JSON → growExact throws
462
+ }
463
+ }
464
+
465
+ // parseMediaType: packed verdict, mediaType at [4..4+len].
466
+ const mt = b.parseMediaType(enc.encode('application/json; charset=utf-8'))
467
+ const mtLen = new DataView(mt.buffer, mt.byteOffset, mt.byteLength).getUint32(0, true)
468
+ if (mtLen === 0 || dec.decode(mt.subarray(4, 4 + mtLen)) !== 'application/json') {
469
+ return false
470
+ }
471
+
472
+ // parseHttpDate: RFC 7231 vector → epoch; malformed → null.
473
+ if (b.parseHttpDate(enc.encode('Sun, 06 Nov 1994 08:49:37 GMT')) !== 784111777n) {
474
+ return false
475
+ }
476
+ if (b.parseHttpDate(enc.encode('not a date')) !== null) {
477
+ return false
478
+ }
479
+
480
+ // parseAcceptEncoding: count, first encoding + q (f32 LE at offset 8+encLen).
481
+ const ae = b.parseAcceptEncoding(enc.encode('gzip, deflate;q=0.5'))
482
+ const aeView = new DataView(ae.buffer, ae.byteOffset, ae.byteLength)
483
+ if (aeView.getUint32(0, true) !== 2) {
484
+ return false
485
+ }
486
+ const aeLen = aeView.getUint32(4, true)
487
+ if (dec.decode(ae.subarray(8, 8 + aeLen)) !== 'gzip') {
488
+ return false
489
+ }
490
+ if (aeView.getFloat32(8 + aeLen, true) !== 1.0) {
491
+ return false
492
+ }
493
+
494
+ // urlEncodeQuery (packed pairs → sorted query text). Build the packed pairs
495
+ // inline — no shared/packed import on the bind-time critical path.
496
+ const qpPacked = new Uint8Array(4 + 2 * (4 + 1 + 4 + 1))
497
+ const qpView = new DataView(qpPacked.buffer)
498
+ qpView.setUint32(0, 2, true)
499
+ let qpOff = 4
500
+ for (const [k, v] of [
501
+ ['a', '1'],
502
+ ['b', '2'],
503
+ ] as const) {
504
+ qpView.setUint32(qpOff, k.length, true)
505
+ qpOff += 4
506
+ qpPacked.set(enc.encode(k), qpOff)
507
+ qpOff += k.length
508
+ qpView.setUint32(qpOff, v.length, true)
509
+ qpOff += 4
510
+ qpPacked.set(enc.encode(v), qpOff)
511
+ qpOff += v.length
512
+ }
513
+ if (b.urlEncodeQuery(qpPacked) !== 'a=1&b=2') {
514
+ return false
515
+ }
516
+
517
+ // urlResolve: RFC 3986 §5.4.1.
518
+ if (b.urlResolve(enc.encode('http://a/b/c/d;p?q'), enc.encode('g')) !== 'http://a/b/c/g') {
519
+ return false
520
+ }
521
+ // mimeFromExtension: known + unknown fallback.
522
+ if (b.mimeFromExtension('.js') !== 'text/javascript') {
523
+ return false
524
+ }
525
+ if (b.mimeFromExtension('nope') !== 'application/octet-stream') {
526
+ return false
527
+ }
528
+ // httpDate string form (RFC 7231 vector).
529
+ if (b.httpDate(784111777) !== 'Sun, 06 Nov 1994 08:49:37 GMT') {
530
+ return false
531
+ }
532
+ // wsFrameEncodeInto.
533
+ const frameInto = new Uint8Array(64)
534
+ if (b.wsFrameEncodeInto(1, SELFTEST_HEX, false, true, frameInto) === 0 || frameInto[0] !== 0x81) {
535
+ return false
536
+ }
537
+
538
+ return true
539
+ }
@@ -0,0 +1,140 @@
1
+ // src/native/ffi/build/util.ts — shared helpers + per-bind context for the
2
+ // BunFFI surface builders.
3
+ //
4
+ // Pure helpers (no symbol/bind state): the C ABI "needed-size" convention
5
+ // (`growExact`), decoder parity (`writeOrThrow`), packed-verdict unpacking
6
+ // (`unpackRateCheck`), length math (`b64Len`, `argon2PhcLength`), the cstring
7
+ // return decoder (`cstr`), and the u8 flag coercion (`flag`).
8
+ //
9
+ // `BuildCtx` carries the per-`bind()` state that every domain builder needs:
10
+ // the probe-gated `lenOrView`/`oneArg` argument adapters and the pooled
11
+ // scratch buffers (encode scratch, jsonSum/date/rate verdict scratches). It is
12
+ // created once in `build()` (src/native/ffi/build.ts) and destructured at the
13
+ // top of each domain builder so the method bodies read exactly as before.
14
+
15
+ /**
16
+ * Per-bind context for the domain builders. Created by `build()`.
17
+ */
18
+ export interface BuildCtx {
19
+ /** `true` when the engine's `buffer`/`buffer_length` ABI pair is live. */
20
+ useBufferLength: boolean
21
+ /**
22
+ * The length slot for a `(ptr,len)` pair: the view itself under
23
+ * `buffer_length` (the engine reads byteLength off it — an atomic snapshot),
24
+ * or the explicit length under `(ptr,usize)`. Bind-time constant → JIT-folded.
25
+ */
26
+ lenOrView: (v: Uint8Array) => Uint8Array | number
27
+ /** Single-buffer input adapter: pass the view twice under `buffer_length`. */
28
+ oneArg: (raw: (...a: unknown[]) => number | bigint, v: Uint8Array) => number | bigint
29
+ /** Growable pooled scratch for STRING-returning encode wrappers. */
30
+ scratchFor: (size: number) => Uint8Array
31
+ /** Pooled 9-byte scratch for `jsonSumIds` (packed `[u8 ok][i64 sum LE]`). */
32
+ jsonSumOut: Uint8Array
33
+ jsonSumView: DataView
34
+ /** Pooled 9-byte scratch for `parseHttpDate`. */
35
+ dateScratch: Uint8Array
36
+ dateScratchView: DataView
37
+ /** Pooled 13-byte scratch for the rate-limiter checks. */
38
+ rateScratch: Uint8Array
39
+ rateScratchView: DataView
40
+ }
41
+
42
+ /** Coerce an optional boolean to the C `u8` flag (1/0). */
43
+ export const flag = (v?: boolean): number => (v ? 1 : 0)
44
+
45
+ /**
46
+ * Write with the C ABI's "needed" convention. The C side returns the EXACT
47
+ * total bytes the result requires: `0` = real error (throw immediately — no
48
+ * re-run, no grow-to-max allocation storm on invalid input), `w > out.length`
49
+ * = buffer too small with `w` the exact required size (allocate once, retry),
50
+ * else `w` is the written count. A miss therefore costs at most ONE retry with
51
+ * an exact buffer — never a doubling loop that re-runs the whole native op
52
+ * (which is what made FFI gzip/brotli/jsonPatch slower than napi).
53
+ */
54
+ export function growExact(
55
+ write: (out: Uint8Array) => number,
56
+ initial: number,
57
+ max: number,
58
+ error: string,
59
+ ): Uint8Array {
60
+ let cap = Math.min(Math.max(initial, 16), max)
61
+ for (;;) {
62
+ const out = new Uint8Array(cap)
63
+ const w = Number(write(out))
64
+ if (w === 0) throw new Error(error)
65
+ if (w <= out.length) return out.subarray(0, w)
66
+ if (w > max) throw new Error(error)
67
+ cap = Math.min(w, max)
68
+ }
69
+ }
70
+
71
+ /** Decoder parity: a `0` write on non-empty input throws (napi `Result`). */
72
+ export function writeOrThrow(w: number, inputLen: number, label: string): number {
73
+ if (w === 0 && inputLen !== 0) {
74
+ throw new Error(`${label}: invalid input or output buffer too small`)
75
+ }
76
+ return w
77
+ }
78
+
79
+ /** Unpack the packed rate-limit verdict `[u8 allowed][u32 remaining LE][i64 reset_ms LE]`. */
80
+ export function unpackRateCheck(
81
+ out: Uint8Array,
82
+ view: DataView,
83
+ ): {
84
+ allowed: boolean
85
+ remaining: number
86
+ resetMs: number
87
+ } {
88
+ return {
89
+ allowed: out[0] === 1,
90
+ remaining: view.getUint32(1, true),
91
+ resetMs: Number(view.getBigInt64(5, true)),
92
+ }
93
+ }
94
+
95
+ /** base64 (no-pad) length of `n` bytes. */
96
+ export const b64Len = (n: number): number =>
97
+ n === 0 ? 0 : Math.ceil(n / 3) * 4 - ((3 - (n % 3)) % 3)
98
+
99
+ /**
100
+ * Exact length of an argon2id PHC string for the given params
101
+ * (m/t/p/salt_len/out_len).
102
+ * Format: `$argon2id$v=19$m=<m>,t=<t>,p=<p>$<salt b64>$<hash b64>`.
103
+ * The salt b64 length is base64-no-pad of the CALLER's salt byte length
104
+ * (`SaltString::encode_b64`) — NOT a fixed 16 bytes. Hardcoding 22
105
+ * under-counts salts >16 bytes (e.g. the 18-byte "salty-salt-16bytes" →
106
+ * 24 chars) and growExact would re-run the whole ~tens-of-ms hash on the
107
+ * retry (measured 2× slowdown). Pre-sizing exactly keeps `passwordHash` a
108
+ * single native pass.
109
+ */
110
+ export function argon2PhcLength(
111
+ m: number,
112
+ t: number,
113
+ p: number,
114
+ saltLen: number,
115
+ outLen: number,
116
+ ): number {
117
+ return (
118
+ 15 + // "$argon2id$v=19$"
119
+ 2 + // "m="
120
+ String(m).length +
121
+ 3 + // ",t="
122
+ String(t).length +
123
+ 3 + // ",p="
124
+ String(p).length +
125
+ 1 + // "$" before the salt
126
+ b64Len(saltLen) + // salt b64 (no-pad — depends on the caller's salt length)
127
+ 1 + // "$" before the hash
128
+ b64Len(outLen)
129
+ )
130
+ }
131
+
132
+ /**
133
+ * cstring-return (the engine clones the NUL-terminated string at call time)
134
+ * → the JS string directly (native transfer — zero encode); a `null` return
135
+ * is the C failure sentinel → throw.
136
+ */
137
+ export function cstr(s: string | null, label: string): string {
138
+ if (s === null) throw new Error(label)
139
+ return s
140
+ }