castrum 0.9.1 → 0.9.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/castrum.linux-x64-gnu.node +0 -0
- package/dist/index.d.ts +239 -5
- package/dist/index.js +3852 -3375
- package/docs/ARCHITECTURE.md +11 -9
- package/docs/BENCHMARKS.md +70 -38
- package/docs/CASE_STUDY.md +10 -7
- package/docs/ENVIRONMENT.md +16 -1
- package/docs/FFI_BUN_GUIDE.md +18 -10
- package/docs/GETTING_STARTED.md +5 -4
- package/docs/HOW_TO_ADD_AN_OP.md +180 -0
- package/docs/INGRESS-ROUTER.md +3 -2
- package/docs/INGRESS.md +6 -3
- package/docs/NATIVE-ROUTE.md +28 -4
- package/docs/REPO_MAP.md +60 -30
- package/docs/adr/0001-two-wire-formats.md +1 -1
- package/docs/ai/LOCAL_DEV.md +81 -0
- package/docs/ai/TREE.md +1070 -0
- package/package.json +24 -23
- package/src/ingress/decode/result-base.ts +10 -2
- package/src/ingress/handlers.ts +45 -290
- package/src/ingress/headers/security.ts +41 -0
- package/src/ingress/index.ts +12 -148
- package/src/ingress/native-route.ts +182 -0
- package/src/ingress/packing/gather-raw-headers.ts +26 -2
- package/src/ingress/packing/route-wire.ts +197 -0
- package/src/ingress/packing/select-headers.ts +8 -1
- package/src/ingress/path-matcher.ts +115 -0
- package/src/ingress/response/baked-response.ts +302 -0
- package/src/ingress/router.ts +42 -2
- package/src/ingress/routes/common.ts +9 -1
- package/src/ingress/routes/json-write.ts +21 -18
- package/src/ingress/routes/native.ts +130 -0
- package/src/ingress/server-node.ts +1 -1
- package/src/ingress/server.ts +38 -99
- package/src/ingress/sync.ts +151 -0
- package/src/ingress/types.ts +9 -0
- package/src/native/ffi/build/codecs.ts +736 -0
- package/src/native/ffi/build/compress.ts +248 -0
- package/src/native/ffi/build/instances.ts +598 -0
- package/src/native/ffi/build/parse.ts +539 -0
- package/src/native/ffi/build/util.ts +140 -0
- package/src/native/ffi/build.ts +94 -0
- package/src/native/ffi/selftest.ts +11 -718
- package/src/native/ffi/types.ts +25 -0
- package/src/native/ffi.ts +36 -1320
- package/src/native/types/instances.ts +5 -0
- package/src/rust-ffi/packed.ts +38 -0
- package/src/rust-ffi/scalar/factories.ts +4 -0
|
@@ -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
|
+
}
|