castrum 0.9.0 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/README.md +153 -630
  2. package/castrum.linux-x64-gnu.node +0 -0
  3. package/dist/index.d.ts +1374 -1772
  4. package/dist/index.js +5570 -4169
  5. package/docs/API.md +337 -0
  6. package/docs/ARCHITECTURE.md +106 -14
  7. package/docs/BENCHMARKS.md +83 -47
  8. package/docs/CASE_STUDY.md +240 -0
  9. package/docs/ENVIRONMENT.md +8 -6
  10. package/docs/FFI_BUN_GUIDE.md +327 -0
  11. package/docs/GETTING_STARTED.md +15 -32
  12. package/docs/INGRESS-ROUTER.md +81 -0
  13. package/docs/INGRESS.md +5 -4
  14. package/docs/NATIVE-ROUTE.md +53 -0
  15. package/docs/REPO_MAP.md +46 -26
  16. package/docs/adr/0003-bun-builtins-delegation.md +6 -5
  17. package/docs/bun-builtins-decision-matrix.md +25 -18
  18. package/index.ts +9 -16
  19. package/package.json +36 -24
  20. package/src/ingress/body.ts +167 -27
  21. package/src/ingress/handlers.ts +91 -15
  22. package/src/ingress/health.ts +1 -0
  23. package/src/ingress/index.ts +39 -27
  24. package/src/ingress/packing/gather-raw-headers.ts +85 -21
  25. package/src/ingress/packing/header-packing.ts +5 -5
  26. package/src/ingress/packing/input-packer.ts +17 -3
  27. package/src/ingress/packing/select-headers.ts +12 -6
  28. package/src/ingress/router.ts +237 -0
  29. package/src/ingress/routes/common.ts +42 -1
  30. package/src/ingress/routes/fallback.ts +2 -2
  31. package/src/ingress/routes/head.ts +3 -13
  32. package/src/ingress/routes/index.ts +5 -4
  33. package/src/ingress/routes/json-write.ts +7 -31
  34. package/src/ingress/routes/options.ts +2 -12
  35. package/src/ingress/routes/read.ts +3 -13
  36. package/src/ingress/routes/responder.ts +159 -0
  37. package/src/ingress/server-node.ts +6 -6
  38. package/src/ingress/server.ts +45 -13
  39. package/src/ingress/types.ts +35 -1
  40. package/src/integration/pipeline.ts +3 -0
  41. package/src/integration/streaming.ts +2 -3
  42. package/src/integration/websocket.ts +4 -3
  43. package/src/loader/batch.ts +4 -0
  44. package/src/loader/cost.ts +1 -0
  45. package/src/loader/index.ts +43 -228
  46. package/src/loader/state.ts +84 -0
  47. package/src/loader/types.ts +145 -0
  48. package/src/native/ffi/constants.ts +58 -0
  49. package/src/native/ffi/selftest.ts +729 -0
  50. package/src/native/ffi/types.ts +474 -0
  51. package/src/native/ffi.ts +1063 -829
  52. package/src/native/loader.ts +52 -6
  53. package/src/native/types/instances.ts +274 -0
  54. package/src/native/types.ts +41 -238
  55. package/src/runtime/builtins.ts +131 -0
  56. package/src/runtime/codec.ts +147 -0
  57. package/src/runtime/detect.ts +36 -0
  58. package/src/runtime/index.ts +28 -0
  59. package/src/runtime/native.ts +35 -0
  60. package/src/runtime/server.ts +31 -0
  61. package/src/runtime/transport.ts +64 -0
  62. package/src/runtime/types.ts +173 -0
  63. package/src/runtime/uuid.ts +45 -0
  64. package/src/runtime/websocket.ts +30 -0
  65. package/src/rust-ffi/addon.ts +1 -1
  66. package/src/rust-ffi/batch/build.ts +155 -0
  67. package/src/rust-ffi/batch/index.ts +8 -0
  68. package/src/rust-ffi/batch/types.ts +92 -0
  69. package/src/rust-ffi/client.ts +5 -6
  70. package/src/rust-ffi/context.ts +39 -10
  71. package/src/rust-ffi/index.ts +28 -17
  72. package/src/rust-ffi/into.ts +21 -0
  73. package/src/rust-ffi/options.ts +1 -1
  74. package/src/rust-ffi/packed.ts +2 -2
  75. package/src/rust-ffi/proven.ts +16 -30
  76. package/src/rust-ffi/scalar/crypto.ts +188 -107
  77. package/src/rust-ffi/scalar/factories.ts +253 -27
  78. package/src/rust-ffi/scalar/hashing.ts +43 -41
  79. package/src/rust-ffi/scalar/http.ts +208 -77
  80. package/src/rust-ffi/scalar/index.ts +3 -3
  81. package/src/rust-ffi/scalar/interface.ts +88 -213
  82. package/src/rust-ffi/scalar/json-packed.ts +175 -0
  83. package/src/rust-ffi/scalar/json.ts +28 -12
  84. package/src/rust-ffi/scalar/payload.ts +159 -42
  85. package/src/rust-ffi/text.ts +31 -5
  86. package/src/selection.ts +27 -31
  87. package/src/shared/adaptive.ts +1 -2
  88. package/src/shared/buffer-pool.ts +72 -17
  89. package/src/shared/bytes.ts +75 -6
  90. package/src/shared/codec.ts +78 -0
  91. package/src/shared/env.ts +4 -4
  92. package/src/shared/log.ts +1 -0
  93. package/src/shared/packed/index.ts +10 -0
  94. package/src/shared/packed/parsers.ts +31 -0
  95. package/src/shared/packed/schema.ts +23 -0
  96. package/src/shared/{packed.ts → packed/wire.ts} +31 -39
  97. package/src/shared/proven.ts +189 -496
  98. package/src/shared/runtime.ts +20 -25
  99. package/src/shared/uuid.ts +12 -28
  100. package/src/data/json-rows.ts +0 -33
  101. package/src/rust-ffi/batch.ts +0 -431
  102. package/src/shared/bench-classify.ts +0 -209
@@ -3,161 +3,277 @@
3
3
  // Mirrors rust/http/* (packed parsers, media type, etag, HTTP dates,
4
4
  // Accept-Encoding, URL codec/join, MIME lookup) plus the `util/validation`
5
5
  // validators and the WebSocket accept-key.
6
+ //
7
+ // Runtime dispatch is centralized in the adapter (`ctx.runtime`): Bun built-in
8
+ // delegations (urlEncode/urlDecode/httpDate) come from `builtins.has(op)` and
9
+ // the native call from `transport.resolve(op)` / `transport.ffi` (bun:ffi
10
+ // first, napi fallback) — no inline `isBun()` / `getBunFFI()`.
6
11
 
7
12
  import type { EncodingPrefResult, MediaTypeResult } from '../../native'
8
- import { getBunFFI } from '../../native/ffi'
9
- import { decoder, encoder } from '../../shared/bytes'
10
- import { isBun } from '../../shared/runtime'
11
- import { type RustClientContext, resolveNative } from '../context'
13
+ import type { BunFFI } from '../../native/ffi'
14
+ import { decoder } from '../../shared/bytes'
15
+ import { decodeUtf8Fatal } from '../../shared/codec'
16
+ import { packPairs } from '../../shared/packed'
17
+ import { writeInto } from '../into'
18
+ import { memoizeFfi, type RustClientContext, resolveNative } from '../context'
12
19
 
13
20
  // Mirror napi `url_decode`'s UTF-8 validation (rust/http/url_codec.rs): only
14
21
  // validate when a high byte is present (the ASCII fast path skips it — same as
15
- // Rust's saw_high check). `url_decode_bytes` / `url_decode_into` skip the check
16
- // entirely, so those ffi bindings are byte-faithful with no extra work.
17
- const FATAL_UTF8 = new TextDecoder('utf-8', { fatal: true })
18
- function ensureValidUtf8(bytes: Uint8Array): void {
22
+ // Rust's saw_high check). On the Bun path the check is a native C-ABI probe
23
+ // (`castrum_utf8_valid`); under Node (no ffi) the codec's fatal TextDecoder is
24
+ // the fallback — no TextDecoder runs on the Bun path.
25
+ function ensureValidUtf8(bytes: Uint8Array, ffi: BunFFI | null): void {
19
26
  for (let i = 0; i < bytes.length; i++) {
20
27
  if ((bytes[i] ?? 0) >= 0x80) {
21
- try {
22
- FATAL_UTF8.decode(bytes)
23
- } catch {
24
- throw new Error('url decode: invalid UTF-8 in decoded output')
28
+ if (ffi) {
29
+ if (!ffi.utf8Valid(bytes)) {
30
+ throw new Error('url decode: invalid UTF-8 in decoded output')
31
+ }
32
+ } else {
33
+ try {
34
+ decodeUtf8Fatal(bytes)
35
+ } catch {
36
+ throw new Error('url decode: invalid UTF-8 in decoded output')
37
+ }
25
38
  }
26
39
  return
27
40
  }
28
41
  }
29
42
  }
30
43
 
44
+ /** Read an unsigned little-endian u32 at `off` (0 past the end). */
45
+ function u32LE(b: Uint8Array, off: number): number {
46
+ return (
47
+ ((b[off] ?? 0) |
48
+ ((b[off + 1] ?? 0) << 8) |
49
+ ((b[off + 2] ?? 0) << 16) |
50
+ ((b[off + 3] ?? 0) << 24)) >>>
51
+ 0
52
+ )
53
+ }
54
+
55
+ /**
56
+ * Unpack the `castrum_parse_media_type` verdict into the napi-shaped result.
57
+ * Layout: `[u32 mediaTypeLen][mediaType][u32 charsetLen (0xFFFFFFFF = none)]
58
+ * [charset][u32 boundaryLen][boundary][u32 paramCount]{[u32 keyLen][key]
59
+ * [u32 valLen][val]}` — charset/boundary also appear in `params`, matching the
60
+ * napi `HashMap` (which keeps every param including charset/boundary).
61
+ */
62
+ function unpackMediaType(packed: Uint8Array): MediaTypeResult {
63
+ let off = 0
64
+ const mtLen = u32LE(packed, off)
65
+ off += 4
66
+ const mediaType = decoder.decode(packed.subarray(off, off + mtLen))
67
+ off += mtLen
68
+ let charset: string | null = null
69
+ const csLen = u32LE(packed, off)
70
+ off += 4
71
+ if (csLen !== 0xffffffff) {
72
+ charset = decoder.decode(packed.subarray(off, off + csLen))
73
+ off += csLen
74
+ }
75
+ let boundary: string | null = null
76
+ const bLen = u32LE(packed, off)
77
+ off += 4
78
+ if (bLen !== 0xffffffff) {
79
+ boundary = decoder.decode(packed.subarray(off, off + bLen))
80
+ off += bLen
81
+ }
82
+ const count = u32LE(packed, off)
83
+ off += 4
84
+ const params: Record<string, string> = {}
85
+ for (let i = 0; i < count; i++) {
86
+ const kLen = u32LE(packed, off)
87
+ off += 4
88
+ const key = decoder.decode(packed.subarray(off, off + kLen))
89
+ off += kLen
90
+ const vLen = u32LE(packed, off)
91
+ off += 4
92
+ const val = decoder.decode(packed.subarray(off, off + vLen))
93
+ off += vLen
94
+ params[key] = val
95
+ }
96
+ return { mediaType, charset, boundary, params }
97
+ }
98
+
99
+ /** Unpack the `castrum_parse_accept_encoding` verdict (f32 q-values) into the
100
+ * napi-shaped array. Layout: `[u32 count]{[u32 encLen][enc][f32 q][u32 order]}`. */
101
+ function unpackAcceptEncoding(packed: Uint8Array): EncodingPrefResult[] {
102
+ const count = u32LE(packed, 0)
103
+ let off = 4
104
+ const out: EncodingPrefResult[] = []
105
+ const view = new DataView(packed.buffer, packed.byteOffset, packed.byteLength)
106
+ for (let i = 0; i < count; i++) {
107
+ const encLen = u32LE(packed, off)
108
+ off += 4
109
+ const encoding = decoder.decode(packed.subarray(off, off + encLen))
110
+ off += encLen
111
+ const q = view.getFloat32(off, true)
112
+ off += 4
113
+ const order = u32LE(packed, off)
114
+ off += 4
115
+ out.push({ encoding, q, order })
116
+ }
117
+ return out
118
+ }
119
+
31
120
  /** HTTP / parsing scalar methods (`Pick<RustScalar, ...>`). */
32
121
  export function buildHttp(ctx: RustClientContext) {
122
+ const { builtins, transport } = ctx.runtime
33
123
  // Resolve the native fns through the shared first-use cache so the hot
34
- // scalar path skips the lazy-addon Proxy get on every call.
124
+ // scalar path skips the lazy-addon Proxy get on every call. `resolveNative`
125
+ // is FFI-first (bun:ffi when live, napi fallback) via the adapter.
35
126
  const n = (name: string) => resolveNative(ctx, name)
127
+ // Hoist the immutable per-runtime builtin-delegation decisions into a
128
+ // bind-time map (BUILTIN_OPS is a module constant, never mutated after
129
+ // load) so the hot path reads an object property instead of a `Set.has`.
130
+ const HAS = {
131
+ urlEncode: builtins.has('urlEncode'),
132
+ urlDecode: builtins.has('urlDecode'),
133
+ httpDate: builtins.has('httpDate'),
134
+ }
135
+ // Lazy-memoized ffi surface: binds on first call (no eager dlopen at
136
+ // import), then a single local read per call — no getter hop.
137
+ const ffi = memoizeFfi(transport)
36
138
 
37
139
  return {
38
- mimeFromExtension(ext: Uint8Array): Uint8Array {
140
+ mimeFromExtension(ext: Uint8Array): Uint8Array | string {
141
+ // FFI-first: the `cstring` ARG is string-only, so bytes are decoded once
142
+ // (cheap CString clone) before the call. napi keeps the memoized-bytes
143
+ // path (cachedMime).
144
+ const f = ffi()
145
+ if (f) return f.mimeFromExtension(decoder.decode(ext)) ?? 'application/octet-stream'
39
146
  return ctx.cachedMime(ext)
40
147
  },
41
- urlEncode(input: Uint8Array): Uint8Array {
148
+ urlEncode(input: Uint8Array): Uint8Array | string {
42
149
  // Under Bun, Bun's native `encodeURIComponent` beats the rust+FFI/NAPI
43
150
  // crossing (~4x on the string path, ~1.3x on bytes) and skips the C-ABI
44
- // call entirely — see docs/bun-builtins-decision-matrix.md. Parity is
45
- // pinned by test/unit/features/url.test.ts (byte-for-byte, incl.
46
- // unicode/emoji); `urlEncode` is always applied to text, so the
47
- // bytes→string→encode→bytes round-trip is lossless.
48
- if (isBun()) return encoder.encode(encodeURIComponent(decoder.decode(input)))
49
- const ffi = getBunFFI()
50
- if (ffi) return ffi.urlEncode(input)
151
+ // call entirely — see docs/bun-builtins-decision-matrix.md. Bun returns
152
+ // the percent-encoded STRING (native transfer). Parity is pinned by
153
+ // test/unit/features/url.test.ts.
154
+ if (HAS.urlEncode) return builtins.urlEncode(input)
51
155
  return n('urlEncode')(input) as Uint8Array
52
156
  },
53
157
  urlDecode(input: Uint8Array): Uint8Array {
54
158
  // Same rationale as `urlEncode`; the strict `urlDecode` already throws on
55
159
  // invalid UTF-8 (ensureValidUtf8 below), matching decodeURIComponent's
56
160
  // URIError (a subclass of Error), so the JS path is behavior-equivalent.
57
- if (isBun()) return encoder.encode(decodeURIComponent(decoder.decode(input)))
58
- const ffi = getBunFFI()
59
- if (ffi) {
60
- const out = ffi.urlDecode(input)
161
+ if (HAS.urlDecode) return builtins.urlDecode(input)
162
+ const f = ffi()
163
+ if (f) {
164
+ const out = f.urlDecode(input)
61
165
  // napi `url_decode` validates UTF-8 (only when a high byte is present);
62
166
  // `url_decode_bytes`/`url_decode_into` do not. This is the strict one.
63
- ensureValidUtf8(out)
167
+ ensureValidUtf8(out, transport.ffi)
64
168
  return out
65
169
  }
66
170
  return n('urlDecode')(input) as Uint8Array
67
171
  },
68
172
  urlDecodeBytes(input: Uint8Array): Uint8Array {
69
- const ffi = getBunFFI()
70
- if (ffi) return ffi.urlDecode(input) // byte semantics match url_decode_bytes
173
+ const f = ffi()
174
+ if (f) return f.urlDecode(input) // byte semantics match url_decode_bytes
71
175
  return n('urlDecodeBytes')(input) as Uint8Array
72
176
  },
73
177
  urlEncodeInto(input: Uint8Array, output: Uint8Array): number {
74
- const ffi = getBunFFI()
75
- if (ffi) return ffi.urlEncodeInto(input, output)
76
178
  return n('urlEncodeInto')(input, output) as number
77
179
  },
78
180
  urlDecodeInto(input: Uint8Array, output: Uint8Array): number {
79
- const ffi = getBunFFI()
80
- if (ffi) return ffi.urlDecodeInto(input, output) // no UTF-8 check, matches napi
181
+ // no UTF-8 check — matches napi url_decode_into on both transports
81
182
  return n('urlDecodeInto')(input, output) as number
82
183
  },
83
184
  validateEmail(input: Uint8Array): boolean {
84
- const ffi = getBunFFI()
85
- if (ffi) return ffi.validateEmail(input)
185
+ // FFI-first: the `cstring` ARG is string-only, so bytes are decoded once
186
+ // (cheap CString clone) before the call — still faster than the old
187
+ // `(ptr,len)` view path (see bench:margin cstring scenario). napi
188
+ // fallback keeps the byte path.
189
+ const f = ffi()
190
+ if (f) return f.validateEmail(decoder.decode(input))
86
191
  return n('validateEmail')(input) as boolean
87
192
  },
88
193
  validateUuid(input: Uint8Array): boolean {
89
- const ffi = getBunFFI()
90
- if (ffi) return ffi.validateUuid(input)
194
+ const f = ffi()
195
+ if (f) return f.validateUuid(decoder.decode(input))
91
196
  return n('validateUuid')(input) as boolean
92
197
  },
93
198
  validateIpv4(input: Uint8Array): boolean {
94
- const ffi = getBunFFI()
95
- if (ffi) return ffi.validateIpv4(input)
199
+ const f = ffi()
200
+ if (f) return f.validateIpv4(decoder.decode(input))
96
201
  return n('validateIpv4')(input) as boolean
97
202
  },
98
203
  validateIpv6(input: Uint8Array): boolean {
99
- const ffi = getBunFFI()
100
- if (ffi) return ffi.validateIpv6(input)
204
+ const f = ffi()
205
+ if (f) return f.validateIpv6(decoder.decode(input))
101
206
  return n('validateIpv6')(input) as boolean
102
207
  },
103
- wsAcceptKey(key: Uint8Array): Uint8Array {
104
- const ffi = getBunFFI()
105
- if (ffi) return ffi.wsAcceptKey(key)
106
- return n('wsAcceptKey')(key) as Uint8Array
208
+ wsAcceptKey(key: Uint8Array): Uint8Array | string {
209
+ // FFI-first: the `cstring` ARG is string-only, so bytes are decoded once
210
+ // (cheap CString clone) before the call. napi fallback keeps the bytes.
211
+ const f = ffi()
212
+ if (f) return f.wsAcceptKey(decoder.decode(key))
213
+ return n('wsAcceptKey')(key) as Uint8Array | string
214
+ },
215
+ wsAcceptKeyInto(key: Uint8Array, output: Uint8Array): number {
216
+ const f = ffi()
217
+ if (f) return f.wsAcceptKeyInto(key, output)
218
+ return writeInto('ws accept key', output, n('wsAcceptKey')(key) as Uint8Array)
107
219
  },
108
220
  httpParseRequestPacked(input: Uint8Array): Uint8Array {
109
- const ffi = getBunFFI()
110
- if (ffi) {
221
+ const f = ffi()
222
+ if (f) {
111
223
  const out = new Uint8Array(input.length * 9 + 16)
112
- const w = ffi.httpParseRequestPackedInto(input, out)
224
+ const w = f.httpParseRequestPackedInto(input, out)
113
225
  return out.subarray(0, w)
114
226
  }
115
227
  return n('httpParseRequestPacked')(input) as Uint8Array
116
228
  },
117
229
  httpParseRequestPackedInto(input: Uint8Array, output: Uint8Array): number {
118
- const ffi = getBunFFI()
119
- if (ffi) return ffi.httpParseRequestPackedInto(input, output)
120
230
  return n('httpParseRequestPackedInto')(input, output) as number
121
231
  },
122
232
  queryParsePacked(input: Uint8Array): Uint8Array {
123
- const ffi = getBunFFI()
124
- if (ffi) {
233
+ const f = ffi()
234
+ if (f) {
125
235
  const out = new Uint8Array(input.length * 9 + 16)
126
- const w = ffi.queryParsePackedInto(input, out)
236
+ const w = f.queryParsePackedInto(input, out)
127
237
  return out.subarray(0, w)
128
238
  }
129
239
  return n('queryParsePacked')(input) as Uint8Array
130
240
  },
131
241
  queryParsePackedInto(input: Uint8Array, output: Uint8Array): number {
132
- const ffi = getBunFFI()
133
- if (ffi) return ffi.queryParsePackedInto(input, output)
134
242
  return n('queryParsePackedInto')(input, output) as number
135
243
  },
136
244
  cookieParsePacked(input: Uint8Array): Uint8Array {
137
- const ffi = getBunFFI()
138
- if (ffi) {
245
+ const f = ffi()
246
+ if (f) {
139
247
  const out = new Uint8Array(input.length * 9 + 16)
140
- const w = ffi.cookieParsePackedInto(input, out)
248
+ const w = f.cookieParsePackedInto(input, out)
141
249
  return out.subarray(0, w)
142
250
  }
143
251
  return n('cookieParsePacked')(input) as Uint8Array
144
252
  },
145
253
  cookieParsePackedInto(input: Uint8Array, output: Uint8Array): number {
146
- const ffi = getBunFFI()
147
- if (ffi) return ffi.cookieParsePackedInto(input, output)
148
254
  return n('cookieParsePackedInto')(input, output) as number
149
255
  },
150
256
  formParsePacked(input: Uint8Array): Uint8Array {
151
- const ffi = getBunFFI()
152
- if (ffi) {
257
+ const f = ffi()
258
+ if (f) {
153
259
  // x-www-form-urlencoded uses the SAME packed-pairs core as query parse.
154
260
  const out = new Uint8Array(input.length * 9 + 16)
155
- const w = ffi.formParsePackedInto(input, out)
261
+ const w = f.formParsePackedInto(input, out)
156
262
  return out.subarray(0, w)
157
263
  }
158
264
  return n('formParsePacked')(input) as Uint8Array
159
265
  },
266
+ formParsePackedInto(input: Uint8Array, output: Uint8Array): number {
267
+ // Pooled sibling of formParsePacked — caller-owned output buffer (the
268
+ // napi addon has no packed `Into` for form, so copy via the allocating
269
+ // call on that path; same wire output, so the copy is exact).
270
+ const f = ffi()
271
+ if (f) return f.formParsePackedInto(input, output)
272
+ return writeInto('form parse', output, n('formParsePacked')(input) as Uint8Array)
273
+ },
160
274
  parseMediaType(input: Uint8Array): MediaTypeResult {
275
+ const f = ffi()
276
+ if (f) return unpackMediaType(f.parseMediaType(input))
161
277
  const r = n('parseMediaType')(input) as MediaTypeResult
162
278
  // napi serializes Option fields as undefined; normalize to null to match
163
279
  // the declared `string | null` public type.
@@ -168,38 +284,53 @@ export function buildHttp(ctx: RustClientContext) {
168
284
  params: r.params,
169
285
  }
170
286
  },
171
- etag(data: Uint8Array, weak?: boolean): Uint8Array {
172
- const ffi = getBunFFI()
173
- if (ffi) return ffi.etag(data, weak)
174
- return n('etag')(data, weak ?? undefined) as Uint8Array
287
+ etag(data: Uint8Array, weak?: boolean): Uint8Array | string {
288
+ return n('etag')(data, weak ?? undefined) as Uint8Array | string
175
289
  },
176
290
  etagInto(data: Uint8Array, output: Uint8Array, weak?: boolean): number {
177
- const ffi = getBunFFI()
178
- if (ffi) return ffi.etagInto(data, output, weak)
179
291
  return n('etagInto')(data, output, weak ?? undefined) as number
180
292
  },
181
- httpDate(secs?: number): Uint8Array {
293
+ httpDate(secs?: number): Uint8Array | string {
182
294
  // Bun's Date.toUTCString (RFC 1123 IMF-fixdate) beats the rust+FFI
183
- // crossing (~3.7x, measured); output is byte-identical for HTTP timestamps.
184
- if (isBun()) {
185
- const t = secs ?? Math.floor(Date.now() / 1000)
186
- return encoder.encode(new Date(t * 1000).toUTCString())
187
- }
295
+ // crossing (~3.7x, measured); Bun returns the STRING (native transfer).
296
+ if (HAS.httpDate) return builtins.httpDate(secs)
188
297
  return n('httpDate')(secs ?? undefined) as Uint8Array
189
298
  },
190
299
  httpDateInto(secs: number | undefined, output: Uint8Array): number {
300
+ // FFI-first: `castrum_http_date_into` writes directly into the caller
301
+ // buffer (no napi crossing). `undefined` → 0 (matches the napi
302
+ // `Option<f64>` default). napi fallback keeps the old path.
303
+ const f = ffi()
304
+ if (f) return f.httpDateInto(secs ?? 0, output)
191
305
  return n('httpDateInto')(secs ?? undefined, output) as number
192
306
  },
193
307
  parseHttpDate(input: Uint8Array): bigint | null {
194
308
  return n('parseHttpDate')(input) as bigint | null
195
309
  },
196
310
  parseAcceptEncoding(input: Uint8Array): EncodingPrefResult[] {
311
+ const f = ffi()
312
+ if (f) return unpackAcceptEncoding(f.parseAcceptEncoding(input))
197
313
  return n('parseAcceptEncoding')(input) as EncodingPrefResult[]
198
314
  },
199
- urlResolve(base: Uint8Array, reference: Uint8Array): Uint8Array {
315
+ urlResolve(base: Uint8Array, reference: Uint8Array): Uint8Array | string {
316
+ // FFI-first: cstring resolution (native transfer — zero encode).
317
+ const f = ffi()
318
+ if (f) {
319
+ const s = f.urlResolve(base, reference)
320
+ if (s === null) throw new Error('url resolve: invalid UTF-8 in input')
321
+ return s
322
+ }
200
323
  return n('urlResolve')(base, reference) as Uint8Array
201
324
  },
202
- urlEncodeQuery(params: Record<string, string>): Uint8Array {
325
+ urlEncodeQuery(params: Record<string, string>): Uint8Array | string {
326
+ // FFI-first: pack the params into the shared pairs layout, then the C
327
+ // side sorts keys (BTreeMap) and percent-encodes → cstring.
328
+ const f = ffi()
329
+ if (f) {
330
+ const s = f.urlEncodeQuery(packPairs(Object.entries(params)))
331
+ if (s === null) throw new Error('url encode query: invalid input')
332
+ return s
333
+ }
203
334
  return n('urlEncodeQuery')(params) as Uint8Array
204
335
  },
205
336
  }
@@ -9,12 +9,12 @@
9
9
  // `rust/` (hashing, json, http, crypto, payload, factories).
10
10
 
11
11
  import type { RustClientContext } from '../context'
12
+ import { buildCrypto } from './crypto'
13
+ import { buildFactories } from './factories'
12
14
  import { buildHashing } from './hashing'
13
- import { buildJson } from './json'
14
15
  import { buildHttp } from './http'
15
- import { buildCrypto } from './crypto'
16
+ import { buildJson } from './json'
16
17
  import { buildPayload } from './payload'
17
- import { buildFactories } from './factories'
18
18
 
19
19
  export type { RustScalar } from './interface'
20
20