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
@@ -0,0 +1,175 @@
1
+ // src/rust-ffi/scalar/json-packed.ts — packed JSON token-stream decoder.
2
+ //
3
+ // Decodes the output of `castrum_json_parse_packed` (layout documented in
4
+ // `src/native/ffi/types.ts`) into a JS value. The C side parses ONCE with
5
+ // sonic-rs and emits a typed token stream with a DEDUPLICATED string table, so
6
+ // the JS side never re-parses JSON text and decodes each unique string exactly
7
+ // once (no per-occurrence decode — a per-occurrence blob + JS `slice` was ~10x
8
+ // worse due to rope strings). This is what makes `rust.jsonParse` competitive
9
+ // with Bun's own `JSON.parse` (the old FFI cstring path re-serialized the whole
10
+ // doc to text and re-parsed it — measured ~3.92x slower on the 5k-row fixture).
11
+
12
+ import { decodeUtf8 } from '../../shared/codec'
13
+ import { isBun } from '../../shared/runtime'
14
+
15
+ const TAG_NULL = 0
16
+ const TAG_FALSE = 1
17
+ const TAG_TRUE = 2
18
+ const TAG_NUMBER = 3
19
+ const TAG_STRING = 4
20
+ const TAG_ARRAY_START = 5
21
+ const TAG_OBJECT_START = 6
22
+ const TAG_ARRAY_END = 7
23
+ const TAG_OBJECT_END = 8
24
+ const TAG_KEY = 9
25
+
26
+ // Hoisted `bun:ffi` codec. The generic `decodeUtf8` (src/shared/codec.ts) does
27
+ // a `require('bun:ffi')` on EVERY call, which is fine for the few strings the
28
+ // ingress path decodes per request but not for a string table with thousands
29
+ // of entries (the 5k-row fixture decodes ~5k unique strings). Resolve the
30
+ // codec ONCE and reuse it; the Node fallback just delegates to the codec.
31
+ let bunFfiCodec: typeof import('bun:ffi') | null | undefined
32
+
33
+ function fastDecodeUtf8(bytes: Uint8Array): string {
34
+ if (bytes.byteLength === 0) return ''
35
+ if (bunFfiCodec === undefined) {
36
+ bunFfiCodec = null
37
+ if (isBun()) {
38
+ try {
39
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
40
+ bunFfiCodec = require('bun:ffi') as typeof import('bun:ffi')
41
+ } catch {
42
+ bunFfiCodec = null
43
+ }
44
+ }
45
+ }
46
+ if (bunFfiCodec) {
47
+ return new bunFfiCodec.CString(bunFfiCodec.ptr(bytes), 0, bytes.byteLength).toString()
48
+ }
49
+ // Node fallback — only reachable if the FFI transport isn't live (this
50
+ // decoder is Bun-only in practice), so reuse the codec's TextDecoder branch.
51
+ return decodeUtf8(bytes)
52
+ }
53
+
54
+ /**
55
+ * Decode a packed token stream (from `ffi.jsonParsePacked`) into a JS value.
56
+ *
57
+ * Stream layout (all integers little-endian):
58
+ * `[u32 strCount]{[u32 len][utf8 bytes]}... [u32 treeLen][tree]` — a
59
+ * deduplicated string table followed by a single value encoded as a
60
+ * start/end-marker token stream:
61
+ * `0=null 1=false 2=true 3=number(f64 LE) 4=string(u32 table idx)
62
+ * 5=array start 6=object start 7=array end 8=object end
63
+ * 9=object key(u32 table idx)`.
64
+ * Object body: `6, (9, keyIdx, value)*, 8`.
65
+ *
66
+ * The result is deep-equal to what `JSON.parse` produces — numbers are emitted
67
+ * as f64 (JS number semantics) and duplicate object keys are last-wins.
68
+ *
69
+ * @param bytes - The packed stream from `ffi.jsonParsePacked`.
70
+ * @returns The parsed JS value.
71
+ *
72
+ * @example
73
+ * ```ts
74
+ * decodeJsonPacked(ffi.jsonParsePacked(enc.encode('{"a":1}'))) // { a: 1 }
75
+ * ```
76
+ */
77
+ export function decodeJsonPacked(bytes: Uint8Array): unknown {
78
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
79
+ let p = 0
80
+ const u32 = (): number => {
81
+ const v = view.getUint32(p, true)
82
+ p += 4
83
+ return v
84
+ }
85
+
86
+ // Deduplicated string table — each unique string decoded exactly once.
87
+ const strCount = u32()
88
+ const strings = new Array<string>(strCount)
89
+ for (let i = 0; i < strCount; i++) {
90
+ const n = u32()
91
+ strings[i] = fastDecodeUtf8(bytes.subarray(p, p + n))
92
+ p += n
93
+ }
94
+
95
+ const treeLen = u32()
96
+ const end = p + treeLen
97
+
98
+ // Iterative decode with an explicit container stack (no recursion).
99
+ let root: unknown
100
+ // frame: { isObj, arr, obj, key } — for arrays values push into `arr`; for
101
+ // objects the pending `key` (set by TAG_KEY) receives the next value.
102
+ interface JsonFrame {
103
+ isObj: boolean
104
+ arr: unknown[]
105
+ obj: Record<string, unknown>
106
+ key: string
107
+ }
108
+ const frames: JsonFrame[] = []
109
+
110
+ const popFrame = (): JsonFrame => {
111
+ const f = frames.pop()
112
+ if (f === undefined) throw new Error('json parse: malformed packed stream')
113
+ return f
114
+ }
115
+
116
+ const attach = (v: unknown): void => {
117
+ const f = frames[frames.length - 1]
118
+ if (f === undefined) {
119
+ root = v
120
+ return
121
+ }
122
+ if (f.isObj) f.obj[f.key] = v
123
+ else f.arr.push(v)
124
+ }
125
+
126
+ while (p < end) {
127
+ // Tag byte — guaranteed in-bounds (p < end <= bytes.length).
128
+ const t = view.getUint8(p)
129
+ p += 1
130
+ switch (t) {
131
+ case TAG_NULL:
132
+ attach(null)
133
+ break
134
+ case TAG_FALSE:
135
+ attach(false)
136
+ break
137
+ case TAG_TRUE:
138
+ attach(true)
139
+ break
140
+ case TAG_NUMBER: {
141
+ const v = view.getFloat64(p, true)
142
+ p += 8
143
+ attach(v)
144
+ break
145
+ }
146
+ case TAG_STRING:
147
+ attach(strings[u32()] as string)
148
+ break
149
+ case TAG_ARRAY_START:
150
+ frames.push({ isObj: false, arr: [], obj: {}, key: '' })
151
+ break
152
+ case TAG_OBJECT_START:
153
+ frames.push({ isObj: true, arr: [], obj: {}, key: '' })
154
+ break
155
+ case TAG_ARRAY_END:
156
+ attach(popFrame().arr)
157
+ break
158
+ case TAG_OBJECT_END:
159
+ attach(popFrame().obj)
160
+ break
161
+ case TAG_KEY: {
162
+ const f = frames[frames.length - 1] as JsonFrame
163
+ f.key = strings[u32()] as string
164
+ break
165
+ }
166
+ default:
167
+ throw new Error('json parse: malformed packed token')
168
+ }
169
+ }
170
+
171
+ if (p !== end || frames.length !== 0) {
172
+ throw new Error('json parse: malformed packed stream')
173
+ }
174
+ return root
175
+ }
@@ -1,36 +1,52 @@
1
1
  // src/rust-ffi/scalar/json.ts — JSON scalar methods.
2
2
  //
3
3
  // Mirrors rust/json/*: validate, DOM parse, id-sum and RFC 6902 patch.
4
+ //
5
+ // Runtime dispatch is centralized in the adapter (`ctx.runtime`): the native
6
+ // call comes from `transport.ffi` / `transport.resolve` (bun:ffi first, napi
7
+ // fallback) — no inline `getBunFFI()`.
4
8
 
5
- import { getBunFFI } from '../../native/ffi'
6
- import type { RustClientContext } from '../context'
9
+ import { type RustClientContext, resolveNative } from '../context'
7
10
  import { asBigInt } from '../options'
11
+ import { decodeJsonPacked } from './json-packed'
8
12
 
9
13
  /** JSON scalar methods (`Pick<RustScalar, ...>`). */
10
14
  export function buildJson(ctx: RustClientContext) {
11
15
  const { addon } = ctx
16
+ const { transport } = ctx.runtime
12
17
 
13
18
  return {
14
19
  jsonValid(input: Uint8Array): boolean {
15
- const ffi = getBunFFI()
16
- if (ffi) return ffi.jsonValid(input)
17
- return addon.jsonValid(input)
20
+ return resolveNative(ctx, 'jsonValid')(input) as boolean
18
21
  },
19
22
  jsonParse(input: Uint8Array): unknown {
23
+ // FFI-first STRUCTURAL parse: the C side parses once with sonic-rs and
24
+ // emits a packed token stream (all strings in ONE blob, referenced by
25
+ // char offsets); the JS decoder slices the blob with NO second JSON text
26
+ // parse. (The old cstring path re-serialized to text and re-parsed it —
27
+ // measured ~3.92x slower than Bun's JSON.parse on the 5k-row fixture.)
28
+ // Invalid JSON → growExact throws (napi parity).
29
+ //
30
+ // Deliberately NOT delegated to JSON.parse despite the ~2x loss: the
31
+ // native path throws on lone-surrogate escapes (`"\ude80"`) — a pinned,
32
+ // reviewed contract (json-packed-roundtrip.test.ts) — while JSON.parse
33
+ // accepts them. Delegating would diverge Bun from Node on that input.
34
+ const f = transport.ffi
35
+ if (f) {
36
+ return decodeJsonPacked(f.jsonParsePacked(input))
37
+ }
20
38
  return addon.jsonParse(input)
21
39
  },
22
40
  jsonSumIds(input: Uint8Array): bigint {
23
41
  // The C ABI returns a packed [u8 ok][i64 sum LE] output, so a legit
24
42
  // zero-sum and invalid input are unambiguous — no napi re-dispatch.
25
- const ffi = getBunFFI()
26
- if (ffi) return ffi.jsonSumIds(input)
27
- return asBigInt(addon.jsonSumIds(input) as unknown)
43
+ return asBigInt(resolveNative(ctx, 'jsonSumIds')(input) as unknown)
28
44
  },
29
- jsonPatch(doc: Uint8Array, patch: Uint8Array): Uint8Array {
30
- const ffi = getBunFFI()
31
- if (ffi) {
45
+ jsonPatch(doc: Uint8Array, patch: Uint8Array): Uint8Array | string {
46
+ const f = transport.ffi
47
+ if (f) {
32
48
  try {
33
- return ffi.jsonPatch(doc, patch)
49
+ return f.jsonPatch(doc, patch)
34
50
  } catch {
35
51
  // The C ABI's 0 is ambiguous (too-small buffer vs a real error) and
36
52
  // carries no message. Re-dispatch to napi for exact error semantics:
@@ -2,15 +2,75 @@
2
2
  //
3
3
  // Mirrors rust/payload/*: gzip/brotli compress, multipart parse, WebSocket
4
4
  // frame codec and SSE event framing.
5
+ //
6
+ // Runtime dispatch is centralized in the adapter (`ctx.runtime`): the Bun
7
+ // gzipCompress delegation comes from `builtins.has(op)` and the native call
8
+ // from `transport.ffi` / `transport.resolve` (bun:ffi first, napi fallback) —
9
+ // no inline `isBun()` / `getBunFFI()`.
5
10
 
6
11
  import type { MultipartPart, WsFrame } from '../../native'
7
- import { getBunFFI } from '../../native/ffi'
8
- import { isBun } from '../../shared/runtime'
9
- import type { RustClientContext } from '../context'
12
+ import { decoder } from '../../shared/bytes'
13
+ import { writeInto } from '../into'
14
+ import { memoizeFfi, type RustClientContext, resolveNative } from '../context'
15
+
16
+ /** Read an unsigned little-endian u32 at `off`. */
17
+ function u32LE(b: Uint8Array, off: number): number {
18
+ return (
19
+ ((b[off] ?? 0) |
20
+ ((b[off + 1] ?? 0) << 8) |
21
+ ((b[off + 2] ?? 0) << 16) |
22
+ ((b[off + 3] ?? 0) << 24)) >>>
23
+ 0
24
+ )
25
+ }
26
+
27
+ /**
28
+ * Unpack the `parts_to_packed` layout (`[u32 count]{[u32 name_len][name]
29
+ * [u8 has_filename][u32 filename_len][filename][u8 has_ct][u32 ct_len][ct]
30
+ * [u32 data_len][data]}`) into the object shape. Slices `data` by copy so the
31
+ * caller owns it (napi parity).
32
+ */
33
+ function unpackMultipart(packed: Uint8Array): MultipartPart[] {
34
+ const count = u32LE(packed, 0)
35
+ let off = 4
36
+ const out: MultipartPart[] = []
37
+ for (let i = 0; i < count; i++) {
38
+ const nameLen = u32LE(packed, off)
39
+ off += 4
40
+ const name = decoder.decode(packed.subarray(off, off + nameLen))
41
+ off += nameLen
42
+ const hasFilename = packed[off] ?? 0
43
+ off += 1
44
+ const filenameLen = u32LE(packed, off)
45
+ off += 4
46
+ const filename =
47
+ hasFilename === 1 ? decoder.decode(packed.subarray(off, off + filenameLen)) : null
48
+ off += filenameLen
49
+ const hasCt = packed[off] ?? 0
50
+ off += 1
51
+ const ctLen = u32LE(packed, off)
52
+ off += 4
53
+ const contentType = hasCt === 1 ? decoder.decode(packed.subarray(off, off + ctLen)) : null
54
+ off += ctLen
55
+ const dataLen = u32LE(packed, off)
56
+ off += 4
57
+ const data = packed.subarray(off, off + dataLen).slice()
58
+ off += dataLen
59
+ out.push({ name, filename, contentType, data })
60
+ }
61
+ return out
62
+ }
10
63
 
11
64
  /** Output / streaming scalar methods (`Pick<RustScalar, ...>`). */
12
65
  export function buildPayload(ctx: RustClientContext) {
13
66
  const { addon } = ctx
67
+ const { builtins, transport } = ctx.runtime
68
+ // Hoist the immutable per-runtime builtin-delegation decision into a
69
+ // bind-time flag (BUILTIN_OPS is a module constant — never mutated) so the
70
+ // hot path reads a property instead of a `Set.has`.
71
+ const HAS = { gzipCompress: builtins.has('gzipCompress') }
72
+ // Lazy-memoized ffi surface: binds on first call, single local read after.
73
+ const ffi = memoizeFfi(transport)
14
74
 
15
75
  return {
16
76
  gzipCompress(data: Uint8Array, level?: number | null): Uint8Array {
@@ -18,46 +78,62 @@ export function buildPayload(ctx: RustClientContext) {
18
78
  // faster than the rust+FFI crossing (decision matrix). Emits valid gzip
19
79
  // (decompression-parity with the addon — only the header OS byte
20
80
  // differs: 0xFF vs 0x03). `gzipCompressInto` (pooled) stays native.
21
- if (isBun()) {
22
- const opts = level != null ? ({ level } as Parameters<typeof Bun.gzipSync>[1]) : undefined
23
- return Bun.gzipSync(data as unknown as Uint8Array<ArrayBuffer>, opts)
24
- }
25
- const ffi = getBunFFI()
26
- if (ffi) return ffi.gzipCompress(data, level ?? undefined)
81
+ if (HAS.gzipCompress) return builtins.gzipCompress(data, level)
82
+ const f = ffi()
83
+ if (f) return f.gzipCompress(data, level ?? undefined)
27
84
  return addon.gzipCompress(data, level ?? null)
28
85
  },
29
86
  gzipCompressInto(data: Uint8Array, output: Uint8Array, level?: number | null): number {
30
- const ffi = getBunFFI()
31
- if (ffi) return ffi.gzipCompressInto(data, output, level ?? undefined)
32
- const bytes = addon.gzipCompress(data, level ?? null)
33
- if (output.length < bytes.length) throw new Error('gzip compress: output buffer too small')
34
- output.set(bytes)
35
- return bytes.length
87
+ const f = ffi()
88
+ if (f) return f.gzipCompressInto(data, output, level ?? undefined)
89
+ return writeInto('gzip compress', output, addon.gzipCompress(data, level ?? null))
36
90
  },
37
91
  gzipDecompress(data: Uint8Array, maxDecompressed?: number | null): Uint8Array {
38
- const ffi = getBunFFI()
39
- if (ffi) return ffi.gzipDecompress(data, maxDecompressed ?? undefined)
92
+ const f = ffi()
93
+ if (f) return f.gzipDecompress(data, maxDecompressed ?? undefined)
40
94
  return addon.gzipDecompress(data, maxDecompressed ?? null)
41
95
  },
96
+ gzipDecompressInto(
97
+ data: Uint8Array,
98
+ output: Uint8Array,
99
+ maxDecompressed?: number | null,
100
+ ): number {
101
+ // Pooled sibling — caller-owned output buffer (the C ABI streams into
102
+ // it via the `_into` core; keeps the 64 MiB decompression-bomb cap).
103
+ const f = ffi()
104
+ if (f) return f.gzipDecompressInto(data, output, maxDecompressed ?? undefined)
105
+ return writeInto('gzip decompress', output, addon.gzipDecompress(data, maxDecompressed ?? null))
106
+ },
42
107
  brotliCompress(data: Uint8Array, quality?: number | null): Uint8Array {
43
- const ffi = getBunFFI()
44
- if (ffi) return ffi.brotliCompress(data, quality ?? undefined)
108
+ const f = ffi()
109
+ if (f) return f.brotliCompress(data, quality ?? undefined)
45
110
  return addon.brotliCompress(data, quality ?? null)
46
111
  },
47
112
  brotliCompressInto(data: Uint8Array, output: Uint8Array, quality?: number | null): number {
48
- const ffi = getBunFFI()
49
- if (ffi) return ffi.brotliCompressInto(data, output, quality ?? undefined)
50
- const bytes = addon.brotliCompress(data, quality ?? null)
51
- if (output.length < bytes.length) throw new Error('brotli compress: output buffer too small')
52
- output.set(bytes)
53
- return bytes.length
113
+ const f = ffi()
114
+ if (f) return f.brotliCompressInto(data, output, quality ?? undefined)
115
+ return writeInto('brotli compress', output, addon.brotliCompress(data, quality ?? null))
54
116
  },
55
117
  brotliDecompress(data: Uint8Array, maxDecompressed?: number | null): Uint8Array {
56
- const ffi = getBunFFI()
57
- if (ffi) return ffi.brotliDecompress(data, maxDecompressed ?? undefined)
118
+ const f = ffi()
119
+ if (f) return f.brotliDecompress(data, maxDecompressed ?? undefined)
58
120
  return addon.brotliDecompress(data, maxDecompressed ?? null)
59
121
  },
122
+ brotliDecompressInto(
123
+ data: Uint8Array,
124
+ output: Uint8Array,
125
+ maxDecompressed?: number | null,
126
+ ): number {
127
+ // Pooled sibling — caller-owned output buffer (keeps the 64 MiB cap).
128
+ const f = ffi()
129
+ if (f) return f.brotliDecompressInto(data, output, maxDecompressed ?? undefined)
130
+ return writeInto('brotli decompress', output, addon.brotliDecompress(data, maxDecompressed ?? null))
131
+ },
60
132
  multipartParse(body: Uint8Array, boundary: Uint8Array): MultipartPart[] {
133
+ // FFI-first: packed parts (castrum_multipart_parse_packed) → unpack to
134
+ // the object shape. napi keeps its object path.
135
+ const f = ffi()
136
+ if (f) return unpackMultipart(f.multipartParsePacked(body, boundary))
61
137
  // Normalize napi `Option<String>` (undefined) → null and expose the
62
138
  // camelCase `contentType` key (napi renames `content_type` to camelCase).
63
139
  return addon.multipartParse(body, boundary).map((p) => ({
@@ -68,15 +144,18 @@ export function buildPayload(ctx: RustClientContext) {
68
144
  }))
69
145
  },
70
146
  multipartParsePacked(body: Uint8Array, boundary: Uint8Array): Uint8Array {
71
- const ffi = getBunFFI()
72
- if (ffi) return ffi.multipartParsePacked(body, boundary)
73
147
  // Zero-copy packed sibling — no JS objects per part (see native types).
74
- return addon.multipartParsePacked(body, boundary)
148
+ return resolveNative(ctx, 'multipartParsePacked')(body, boundary) as Uint8Array
149
+ },
150
+ multipartParsePackedInto(body: Uint8Array, boundary: Uint8Array, output: Uint8Array): number {
151
+ // Pooled sibling — caller-owned output buffer (the napi addon has no
152
+ // packed `Into` for multipart, so copy via the allocating call there).
153
+ const f = ffi()
154
+ if (f) return f.multipartParsePackedInto(body, boundary, output)
155
+ return writeInto('multipart parse', output, addon.multipartParsePacked(body, boundary))
75
156
  },
76
157
  wsFrameEncode(opcode: number, payload: Uint8Array, mask: boolean, fin: boolean): Uint8Array {
77
- const ffi = getBunFFI()
78
- if (ffi) return ffi.wsFrameEncode(opcode, payload, mask, fin)
79
- return addon.wsFrameEncode(opcode, payload, mask, fin)
158
+ return resolveNative(ctx, 'wsFrameEncode')(opcode, payload, mask, fin) as Uint8Array
80
159
  },
81
160
  wsFrameEncodeInto(
82
161
  opcode: number,
@@ -85,18 +164,15 @@ export function buildPayload(ctx: RustClientContext) {
85
164
  fin: boolean,
86
165
  output: Uint8Array,
87
166
  ): number {
88
- const ffi = getBunFFI()
89
- if (ffi) return ffi.wsFrameEncodeInto(opcode, payload, mask, fin, output)
90
- const bytes = addon.wsFrameEncode(opcode, payload, mask, fin)
91
- if (output.length < bytes.length) throw new Error('ws frame encode: output buffer too small')
92
- output.set(bytes)
93
- return bytes.length
167
+ const f = ffi()
168
+ if (f) return f.wsFrameEncodeInto(opcode, payload, mask, fin, output)
169
+ return writeInto('ws frame encode', output, addon.wsFrameEncode(opcode, payload, mask, fin))
94
170
  },
95
171
  wsFrameDecode(data: Uint8Array): WsFrame | null {
96
- const ffi = getBunFFI()
97
- if (ffi) {
172
+ const f = ffi()
173
+ if (f) {
98
174
  // C ABI returns packed [flags][opcode][u32 len][payload]; decode here.
99
- const packed = ffi.wsFrameDecodePacked(data)
175
+ const packed = f.wsFrameDecodePacked(data)
100
176
  if (packed === null) return null
101
177
  const payloadLen =
102
178
  ((packed[2] ?? 0) |
@@ -112,13 +188,54 @@ export function buildPayload(ctx: RustClientContext) {
112
188
  }
113
189
  return addon.wsFrameDecode(data)
114
190
  },
191
+ wsFrameDecodePackedInto(data: Uint8Array, output: Uint8Array): number | null {
192
+ // Pooled sibling — caller-owned output buffer sized ≥ data.length + 6
193
+ // (6-byte header + payload). null = malformed frame. The napi addon has
194
+ // no packed decode, so that path re-packs the decoded object instead.
195
+ const f = ffi()
196
+ if (f) return f.wsFrameDecodePackedInto(data, output)
197
+ const frame = addon.wsFrameDecode(data)
198
+ if (frame === null) return null
199
+ const need = 6 + frame.payload.length
200
+ if (output.length < need) throw new Error('ws frame decode: output buffer too small')
201
+ output[0] = frame.fin ? 1 : 0
202
+ output[1] = frame.opcode
203
+ output[2] = frame.payload.length & 0xff
204
+ output[3] = (frame.payload.length >> 8) & 0xff
205
+ output[4] = (frame.payload.length >> 16) & 0xff
206
+ output[5] = (frame.payload.length >> 24) & 0xff
207
+ output.set(frame.payload, 6)
208
+ return need
209
+ },
115
210
  sseEncodeEvent(
116
211
  event: string | null,
117
212
  data: Uint8Array,
118
213
  id: string | null,
119
214
  retry: number | null,
120
215
  ): Uint8Array {
216
+ // FFI-first: the C-ABI `castrum_sse_encode_into` encodes directly into a
217
+ // caller buffer (no napi crossing / String args). napi fallback mirrors
218
+ // the Option semantics (null → line omitted).
219
+ const f = ffi()
220
+ if (f) return f.sseEncodeEvent(event, data, id, retry)
121
221
  return addon.sseEncodeEvent(event ?? null, data, id ?? null, retry ?? null)
122
222
  },
223
+ sseEncodeEventInto(
224
+ event: string | null,
225
+ data: Uint8Array,
226
+ id: string | null,
227
+ retry: number | null,
228
+ output: Uint8Array,
229
+ ): number {
230
+ // Pooled sibling — caller-owned output buffer (no per-call alloc). The
231
+ // napi addon has no `_into` variant, so that path re-encodes into the
232
+ // buffer via the allocating call.
233
+ const f = ffi()
234
+ if (f) return f.sseEncodeEventInto(event, data, id, retry, output)
235
+ const bytes = addon.sseEncodeEvent(event ?? null, data, id ?? null, retry ?? null)
236
+ if (output.length < bytes.length) throw new Error('sse encode: output buffer too small')
237
+ output.set(bytes)
238
+ return bytes.length
239
+ },
123
240
  }
124
241
  }
@@ -2,10 +2,14 @@
2
2
  //
3
3
  // `rust.text.*` is the ergonomic string API over the byte-level native
4
4
  // functions. Pure wrappers: encode input, call native, decode output.
5
+ //
6
+ // Runtime dispatch is centralized in the adapter (`ctx.runtime`): the Bun
7
+ // built-in delegations (urlEncode/urlDecode) come from `builtins.has(op)` and
8
+ // the native call from `transport.ffi` / `transport.resolve` (bun:ffi first,
9
+ // napi fallback) — no inline `isBun()` / `getBunFFI()`.
5
10
 
6
11
  import { decoder, encoder } from '../shared/bytes'
7
- import { isBun } from '../shared/runtime'
8
- import { resolveNative, type RustClientContext } from './context'
12
+ import { type RustClientContext, resolveNative } from './context'
9
13
 
10
14
  /** String-oriented FFI namespace. */
11
15
  export interface RustText {
@@ -49,6 +53,7 @@ export interface RustText {
49
53
 
50
54
  /** Build the `text` namespace for a client context. */
51
55
  export function buildText(ctx: RustClientContext): RustText {
56
+ const { builtins, transport } = ctx.runtime
52
57
  // String memo: the byte path already caches the native bytes; this avoids the
53
58
  // encode → native lookup → slice → decode round-trip for repeated extension
54
59
  // strings (the common case) — 0 allocations on a cache hit.
@@ -58,7 +63,13 @@ export function buildText(ctx: RustClientContext): RustText {
58
63
  mimeFromExtension(ext) {
59
64
  let mime = mimeStrCache.get(ext)
60
65
  if (mime === undefined) {
61
- mime = decoder.decode(ctx.cachedMime(encoder.encode(ext)))
66
+ // FFI-first: `cstring` ARG + cstring MIME return (native transfer both
67
+ // ways — the engine transcodes the extension in-engine, no JS encode).
68
+ // napi keeps the memoized-bytes path (cachedMime → decode).
69
+ const f = transport.ffi
70
+ mime = f
71
+ ? (f.mimeFromExtension(ext) ?? 'application/octet-stream')
72
+ : decoder.decode(ctx.cachedMime(encoder.encode(ext)))
62
73
  mimeStrCache.set(ext, mime)
63
74
  }
64
75
  return mime
@@ -67,28 +78,43 @@ export function buildText(ctx: RustClientContext): RustText {
67
78
  // Bun's native encodeURIComponent beats the rust+FFI/NAPI string crossing
68
79
  // (~4x, measured) with byte-for-byte parity (RFC 3986 unreserved set) —
69
80
  // see docs/bun-builtins-decision-matrix.md.
70
- if (isBun()) return encodeURIComponent(input)
81
+ if (builtins.has('urlEncode')) return builtins.urlEncodeStr(input)
71
82
  return resolveNative(ctx, 'urlEncodeStr')(input) as string
72
83
  },
73
84
  urlDecode(input) {
74
85
  // decodeURIComponent matches the rust decoder on valid input and also
75
86
  // throws on malformed/invalid UTF-8 (URIError, an Error subclass).
76
- if (isBun()) return decodeURIComponent(input)
87
+ if (builtins.has('urlDecode')) return builtins.urlDecodeStr(input)
77
88
  return resolveNative(ctx, 'urlDecodeStr')(input) as string
78
89
  },
79
90
  wsAcceptKey(key) {
91
+ // FFI-first: `cstring` ARG + cstring return (native transfer both ways —
92
+ // the engine transcodes the key in-engine, no JS encode). napi keeps the
93
+ // encode/decode path.
94
+ const f = transport.ffi
95
+ if (f) return f.wsAcceptKey(key)
80
96
  return decoder.decode(resolveNative(ctx, 'wsAcceptKey')(encoder.encode(key)) as Uint8Array)
81
97
  },
82
98
  validateEmail(input) {
99
+ // FFI-first: `cstring` ARG (the engine transcodes the string in-engine —
100
+ // no JS encode). napi fallback keeps the encode path.
101
+ const f = transport.ffi
102
+ if (f) return f.validateEmail(input)
83
103
  return resolveNative(ctx, 'validateEmail')(encoder.encode(input)) as boolean
84
104
  },
85
105
  validateUuid(input) {
106
+ const f = transport.ffi
107
+ if (f) return f.validateUuid(input)
86
108
  return resolveNative(ctx, 'validateUuid')(encoder.encode(input)) as boolean
87
109
  },
88
110
  validateIpv4(input) {
111
+ const f = transport.ffi
112
+ if (f) return f.validateIpv4(input)
89
113
  return resolveNative(ctx, 'validateIpv4')(encoder.encode(input)) as boolean
90
114
  },
91
115
  validateIpv6(input) {
116
+ const f = transport.ffi
117
+ if (f) return f.validateIpv6(input)
92
118
  return resolveNative(ctx, 'validateIpv6')(encoder.encode(input)) as boolean
93
119
  },
94
120
  }