castrum 0.7.0 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (207) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +154 -368
  3. package/castrum.linux-x64-gnu.node +0 -0
  4. package/dist/index.d.ts +2763 -0
  5. package/dist/index.js +8707 -0
  6. package/docs/API.md +337 -0
  7. package/docs/ARCHITECTURE.md +507 -0
  8. package/docs/BENCHMARKS.md +343 -0
  9. package/docs/CASE_STUDY.md +240 -0
  10. package/docs/ENVIRONMENT.md +81 -0
  11. package/docs/FFI_BUN_GUIDE.md +327 -0
  12. package/docs/GETTING_STARTED.md +192 -0
  13. package/docs/INGRESS-ROUTER.md +81 -0
  14. package/docs/INGRESS.md +746 -0
  15. package/docs/NATIVE-ROUTE.md +53 -0
  16. package/docs/REPO_MAP.md +279 -0
  17. package/docs/adr/0001-two-wire-formats.md +39 -0
  18. package/docs/adr/0002-zero-dom-fast-schema.md +38 -0
  19. package/docs/adr/0003-bun-builtins-delegation.md +43 -0
  20. package/docs/bun-builtins-decision-matrix.md +115 -0
  21. package/index.ts +44 -8
  22. package/package.json +98 -33
  23. package/src/ingress/body.ts +289 -0
  24. package/src/ingress/constants.ts +220 -48
  25. package/src/ingress/context.ts +136 -0
  26. package/src/ingress/decode/baked-result.ts +121 -0
  27. package/src/ingress/decode/fast-result.ts +160 -0
  28. package/src/ingress/decode/packed-sections.ts +56 -0
  29. package/src/ingress/decode/result-base.ts +172 -0
  30. package/src/ingress/errors.ts +72 -0
  31. package/src/ingress/fast.ts +152 -1021
  32. package/src/ingress/handlers.ts +639 -1001
  33. package/src/ingress/headers/baked-templates.ts +102 -0
  34. package/src/ingress/headers/cors.ts +44 -0
  35. package/src/ingress/headers/fast-templates.ts +238 -0
  36. package/src/ingress/headers/hsts.ts +83 -0
  37. package/src/ingress/health.ts +68 -0
  38. package/src/ingress/index.ts +142 -405
  39. package/src/ingress/metrics.ts +132 -0
  40. package/src/ingress/options.ts +197 -0
  41. package/src/ingress/packing/gather-raw-headers.ts +139 -0
  42. package/src/ingress/packing/header-packing.ts +75 -0
  43. package/src/ingress/packing/input-packer.ts +164 -0
  44. package/src/ingress/packing/scratch.ts +50 -0
  45. package/src/ingress/packing/select-headers.ts +102 -0
  46. package/src/ingress/response/error-bodies.ts +75 -0
  47. package/src/ingress/response/terminal.ts +59 -0
  48. package/src/ingress/router.ts +237 -0
  49. package/src/ingress/routes/common.ts +79 -0
  50. package/src/ingress/routes/delete.ts +14 -0
  51. package/src/ingress/routes/echo.ts +107 -0
  52. package/src/ingress/routes/fallback.ts +23 -0
  53. package/src/ingress/routes/head.ts +27 -0
  54. package/src/ingress/routes/index.ts +11 -0
  55. package/src/ingress/routes/json-write.ts +129 -0
  56. package/src/ingress/routes/options.ts +26 -0
  57. package/src/ingress/routes/read.ts +35 -0
  58. package/src/ingress/routes/responder.ts +159 -0
  59. package/src/ingress/server-node.ts +376 -0
  60. package/src/ingress/server.ts +475 -0
  61. package/src/ingress/shared.ts +132 -0
  62. package/src/ingress/status.ts +97 -0
  63. package/src/ingress/types.ts +191 -0
  64. package/src/integration/batch.ts +58 -0
  65. package/src/integration/index.ts +29 -0
  66. package/src/integration/pipeline.ts +220 -0
  67. package/src/integration/streaming.ts +78 -0
  68. package/src/integration/websocket.ts +93 -0
  69. package/src/loader/batch.ts +137 -0
  70. package/src/loader/cost.ts +105 -0
  71. package/src/loader/index.ts +745 -0
  72. package/src/loader/ops.ts +657 -0
  73. package/src/loader/state.ts +84 -0
  74. package/src/loader/types.ts +145 -0
  75. package/src/native/ffi/constants.ts +58 -0
  76. package/src/native/ffi/selftest.ts +729 -0
  77. package/src/native/ffi/types.ts +474 -0
  78. package/src/native/ffi.ts +1775 -0
  79. package/src/native/index.ts +8 -207
  80. package/src/native/loader.ts +240 -0
  81. package/src/native/types/instances.ts +274 -0
  82. package/src/native/types.ts +401 -0
  83. package/src/runtime/builtins.ts +131 -0
  84. package/src/runtime/codec.ts +147 -0
  85. package/src/runtime/detect.ts +36 -0
  86. package/src/runtime/index.ts +28 -0
  87. package/src/runtime/native.ts +35 -0
  88. package/src/runtime/server.ts +31 -0
  89. package/src/runtime/transport.ts +64 -0
  90. package/src/runtime/types.ts +173 -0
  91. package/src/runtime/uuid.ts +45 -0
  92. package/src/runtime/websocket.ts +30 -0
  93. package/src/rust-ffi/addon.ts +10 -0
  94. package/src/rust-ffi/batch/build.ts +155 -0
  95. package/src/rust-ffi/batch/index.ts +8 -0
  96. package/src/rust-ffi/batch/types.ts +92 -0
  97. package/src/rust-ffi/client.ts +87 -0
  98. package/src/rust-ffi/context.ts +193 -0
  99. package/src/rust-ffi/index.ts +51 -541
  100. package/src/rust-ffi/into.ts +21 -0
  101. package/src/rust-ffi/options.ts +113 -0
  102. package/src/rust-ffi/packed.ts +253 -0
  103. package/src/rust-ffi/proven.ts +28 -0
  104. package/src/rust-ffi/scalar/crypto.ts +346 -0
  105. package/src/rust-ffi/scalar/factories.ts +459 -0
  106. package/src/rust-ffi/scalar/hashing.ts +74 -0
  107. package/src/rust-ffi/scalar/http.ts +337 -0
  108. package/src/rust-ffi/scalar/index.ts +31 -0
  109. package/src/rust-ffi/scalar/interface.ts +325 -0
  110. package/src/rust-ffi/scalar/json-packed.ts +175 -0
  111. package/src/rust-ffi/scalar/json.ts +61 -0
  112. package/src/rust-ffi/scalar/payload.ts +241 -0
  113. package/src/rust-ffi/text.ts +121 -0
  114. package/src/selection.json +194 -0
  115. package/src/selection.ts +73 -0
  116. package/src/shared/adaptive.ts +67 -0
  117. package/src/shared/buffer-pool.ts +264 -0
  118. package/src/shared/bytes.ts +108 -9
  119. package/src/shared/codec.ts +78 -0
  120. package/src/shared/env.ts +20 -0
  121. package/src/shared/log.ts +75 -0
  122. package/src/shared/metrics.ts +247 -0
  123. package/src/shared/packed/index.ts +10 -0
  124. package/src/shared/packed/parsers.ts +31 -0
  125. package/src/shared/packed/schema.ts +23 -0
  126. package/src/shared/packed/wire.ts +504 -0
  127. package/src/shared/proven.ts +289 -0
  128. package/src/shared/request-id.ts +67 -0
  129. package/src/shared/response.ts +104 -0
  130. package/src/shared/runtime.ts +29 -0
  131. package/src/shared/trace.ts +81 -0
  132. package/src/shared/uuid.ts +15 -0
  133. package/Cargo.toml +0 -73
  134. package/bench.ts +0 -3
  135. package/rust/batch.rs +0 -195
  136. package/rust/cookie_parser.rs +0 -70
  137. package/rust/cors.rs +0 -263
  138. package/rust/hashing.rs +0 -164
  139. package/rust/headers.rs +0 -234
  140. package/rust/hmac_sha256.rs +0 -113
  141. package/rust/http_parser.rs +0 -132
  142. package/rust/ingress.rs +0 -616
  143. package/rust/ingress_constants.rs +0 -89
  144. package/rust/ip_trust.rs +0 -185
  145. package/rust/json_ops.rs +0 -66
  146. package/rust/json_patch_ops.rs +0 -25
  147. package/rust/json_schema.rs +0 -151
  148. package/rust/json_ser.rs +0 -429
  149. package/rust/lib.rs +0 -48
  150. package/rust/method.rs +0 -141
  151. package/rust/mime_lookup.rs +0 -76
  152. package/rust/output.rs +0 -238
  153. package/rust/proxy.rs +0 -90
  154. package/rust/query_parser.rs +0 -141
  155. package/rust/random_token.rs +0 -52
  156. package/rust/rate_limit.rs +0 -203
  157. package/rust/terminal.rs +0 -95
  158. package/rust/unit_tests.rs +0 -843
  159. package/rust/url_codec.rs +0 -133
  160. package/rust/util.rs +0 -537
  161. package/rust/validation.rs +0 -107
  162. package/rust/websocket.rs +0 -23
  163. package/src/baseline/index.ts +0 -12
  164. package/src/baseline/tasks/cookie.ts +0 -22
  165. package/src/baseline/tasks/hashing.ts +0 -18
  166. package/src/baseline/tasks/hmac.ts +0 -38
  167. package/src/baseline/tasks/http.ts +0 -62
  168. package/src/baseline/tasks/json-patch.ts +0 -12
  169. package/src/baseline/tasks/json.ts +0 -50
  170. package/src/baseline/tasks/mime.ts +0 -5
  171. package/src/baseline/tasks/query.ts +0 -14
  172. package/src/baseline/tasks/token.ts +0 -15
  173. package/src/baseline/tasks/url.ts +0 -11
  174. package/src/baseline/tasks/validation.ts +0 -19
  175. package/src/baseline/tasks/websocket.ts +0 -14
  176. package/src/bench/assert.ts +0 -70
  177. package/src/bench/checks.ts +0 -227
  178. package/src/bench/checksum.ts +0 -27
  179. package/src/bench/comparisons.ts +0 -70
  180. package/src/bench/concurrent-worker.ts +0 -135
  181. package/src/bench/fixtures.ts +0 -233
  182. package/src/bench/index.ts +0 -2
  183. package/src/bench/measure.ts +0 -177
  184. package/src/bench/now.ts +0 -3
  185. package/src/bench/report.ts +0 -79
  186. package/src/bench/run.ts +0 -57
  187. package/src/bench/tasks/complex.ts +0 -232
  188. package/src/bench/tasks/concurrent.ts +0 -213
  189. package/src/bench/tasks/cookie.ts +0 -44
  190. package/src/bench/tasks/hashing.ts +0 -33
  191. package/src/bench/tasks/hmac.ts +0 -36
  192. package/src/bench/tasks/http.ts +0 -50
  193. package/src/bench/tasks/index.ts +0 -52
  194. package/src/bench/tasks/json-patch.ts +0 -21
  195. package/src/bench/tasks/json.ts +0 -33
  196. package/src/bench/tasks/mime.ts +0 -21
  197. package/src/bench/tasks/query.ts +0 -44
  198. package/src/bench/tasks/stress.ts +0 -142
  199. package/src/bench/tasks/token.ts +0 -20
  200. package/src/bench/tasks/url.ts +0 -34
  201. package/src/bench/tasks/validation.ts +0 -57
  202. package/src/bench/tasks/websocket.ts +0 -21
  203. package/src/bench/types.ts +0 -60
  204. package/src/data/json-rows.ts +0 -33
  205. package/src/ingress/packed-input.ts +0 -59
  206. package/src/shared/json.ts +0 -18
  207. package/src/shared/packed.ts +0 -316
@@ -0,0 +1,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
+ }
@@ -0,0 +1,61 @@
1
+ // src/rust-ffi/scalar/json.ts — JSON scalar methods.
2
+ //
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()`.
8
+
9
+ import { type RustClientContext, resolveNative } from '../context'
10
+ import { asBigInt } from '../options'
11
+ import { decodeJsonPacked } from './json-packed'
12
+
13
+ /** JSON scalar methods (`Pick<RustScalar, ...>`). */
14
+ export function buildJson(ctx: RustClientContext) {
15
+ const { addon } = ctx
16
+ const { transport } = ctx.runtime
17
+
18
+ return {
19
+ jsonValid(input: Uint8Array): boolean {
20
+ return resolveNative(ctx, 'jsonValid')(input) as boolean
21
+ },
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
+ }
38
+ return addon.jsonParse(input)
39
+ },
40
+ jsonSumIds(input: Uint8Array): bigint {
41
+ // The C ABI returns a packed [u8 ok][i64 sum LE] output, so a legit
42
+ // zero-sum and invalid input are unambiguous — no napi re-dispatch.
43
+ return asBigInt(resolveNative(ctx, 'jsonSumIds')(input) as unknown)
44
+ },
45
+ jsonPatch(doc: Uint8Array, patch: Uint8Array): Uint8Array | string {
46
+ const f = transport.ffi
47
+ if (f) {
48
+ try {
49
+ return f.jsonPatch(doc, patch)
50
+ } catch {
51
+ // The C ABI's 0 is ambiguous (too-small buffer vs a real error) and
52
+ // carries no message. Re-dispatch to napi for exact error semantics:
53
+ // it either succeeds (too-small case) or throws the contextual
54
+ // "invalid document"/"invalid patch"/"apply failed" message.
55
+ return addon.jsonPatch(doc, patch)
56
+ }
57
+ }
58
+ return addon.jsonPatch(doc, patch)
59
+ },
60
+ }
61
+ }
@@ -0,0 +1,241 @@
1
+ // src/rust-ffi/scalar/payload.ts — Output / streaming scalar methods.
2
+ //
3
+ // Mirrors rust/payload/*: gzip/brotli compress, multipart parse, WebSocket
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()`.
10
+
11
+ import type { MultipartPart, WsFrame } from '../../native'
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
+ }
63
+
64
+ /** Output / streaming scalar methods (`Pick<RustScalar, ...>`). */
65
+ export function buildPayload(ctx: RustClientContext) {
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)
74
+
75
+ return {
76
+ gzipCompress(data: Uint8Array, level?: number | null): Uint8Array {
77
+ // Optimal by default under Bun: `Bun.gzipSync` (native zlib) is ~2x
78
+ // faster than the rust+FFI crossing (decision matrix). Emits valid gzip
79
+ // (decompression-parity with the addon — only the header OS byte
80
+ // differs: 0xFF vs 0x03). `gzipCompressInto` (pooled) stays native.
81
+ if (HAS.gzipCompress) return builtins.gzipCompress(data, level)
82
+ const f = ffi()
83
+ if (f) return f.gzipCompress(data, level ?? undefined)
84
+ return addon.gzipCompress(data, level ?? null)
85
+ },
86
+ gzipCompressInto(data: Uint8Array, output: Uint8Array, level?: number | null): number {
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))
90
+ },
91
+ gzipDecompress(data: Uint8Array, maxDecompressed?: number | null): Uint8Array {
92
+ const f = ffi()
93
+ if (f) return f.gzipDecompress(data, maxDecompressed ?? undefined)
94
+ return addon.gzipDecompress(data, maxDecompressed ?? null)
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
+ },
107
+ brotliCompress(data: Uint8Array, quality?: number | null): Uint8Array {
108
+ const f = ffi()
109
+ if (f) return f.brotliCompress(data, quality ?? undefined)
110
+ return addon.brotliCompress(data, quality ?? null)
111
+ },
112
+ brotliCompressInto(data: Uint8Array, output: Uint8Array, quality?: number | null): number {
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))
116
+ },
117
+ brotliDecompress(data: Uint8Array, maxDecompressed?: number | null): Uint8Array {
118
+ const f = ffi()
119
+ if (f) return f.brotliDecompress(data, maxDecompressed ?? undefined)
120
+ return addon.brotliDecompress(data, maxDecompressed ?? null)
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
+ },
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))
137
+ // Normalize napi `Option<String>` (undefined) → null and expose the
138
+ // camelCase `contentType` key (napi renames `content_type` to camelCase).
139
+ return addon.multipartParse(body, boundary).map((p) => ({
140
+ name: p.name,
141
+ filename: p.filename ?? null,
142
+ contentType: p.contentType ?? null,
143
+ data: p.data,
144
+ }))
145
+ },
146
+ multipartParsePacked(body: Uint8Array, boundary: Uint8Array): Uint8Array {
147
+ // Zero-copy packed sibling — no JS objects per part (see native types).
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))
156
+ },
157
+ wsFrameEncode(opcode: number, payload: Uint8Array, mask: boolean, fin: boolean): Uint8Array {
158
+ return resolveNative(ctx, 'wsFrameEncode')(opcode, payload, mask, fin) as Uint8Array
159
+ },
160
+ wsFrameEncodeInto(
161
+ opcode: number,
162
+ payload: Uint8Array,
163
+ mask: boolean,
164
+ fin: boolean,
165
+ output: Uint8Array,
166
+ ): number {
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))
170
+ },
171
+ wsFrameDecode(data: Uint8Array): WsFrame | null {
172
+ const f = ffi()
173
+ if (f) {
174
+ // C ABI returns packed [flags][opcode][u32 len][payload]; decode here.
175
+ const packed = f.wsFrameDecodePacked(data)
176
+ if (packed === null) return null
177
+ const payloadLen =
178
+ ((packed[2] ?? 0) |
179
+ ((packed[3] ?? 0) << 8) |
180
+ ((packed[4] ?? 0) << 16) |
181
+ ((packed[5] ?? 0) << 24)) >>>
182
+ 0
183
+ return {
184
+ fin: (packed[0] ?? 0) === 1,
185
+ opcode: packed[1] ?? 0,
186
+ payload: packed.subarray(6, 6 + payloadLen),
187
+ }
188
+ }
189
+ return addon.wsFrameDecode(data)
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
+ },
210
+ sseEncodeEvent(
211
+ event: string | null,
212
+ data: Uint8Array,
213
+ id: string | null,
214
+ retry: number | null,
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)
221
+ return addon.sseEncodeEvent(event ?? null, data, id ?? null, retry ?? null)
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
+ },
240
+ }
241
+ }
@@ -0,0 +1,121 @@
1
+ // src/rust-ffi/text.ts — String-oriented FFI namespace (text in → text out).
2
+ //
3
+ // `rust.text.*` is the ergonomic string API over the byte-level native
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()`.
10
+
11
+ import { decoder, encoder } from '../shared/bytes'
12
+ import { type RustClientContext, resolveNative } from './context'
13
+
14
+ /** String-oriented FFI namespace. */
15
+ export interface RustText {
16
+ /**
17
+ * Resolve a lowercase file extension to a MIME type string (e.g. `"html"` →
18
+ * `"text/html"`). Unknown extensions return `"application/octet-stream"`.
19
+ * @param ext the file extension WITHOUT a leading dot, case-insensitive.
20
+ * @returns the MIME type for `ext`.
21
+ */
22
+ mimeFromExtension(ext: string): string
23
+ /**
24
+ * RFC 3986 percent-encode a string (the `encodeURIComponent` unreserved
25
+ * set). Under Bun this delegates to the native `encodeURIComponent` for
26
+ * ~4x speed with byte-for-byte parity.
27
+ * @param input the string to encode.
28
+ * @returns the percent-encoded string.
29
+ */
30
+ urlEncode(input: string): string
31
+ /**
32
+ * Percent-decode a string. Throws on malformed `%XX` or invalid UTF-8
33
+ * output (parity with `decodeURIComponent`, which is used under Bun).
34
+ * @param input the percent-encoded string to decode.
35
+ * @returns the decoded string.
36
+ */
37
+ urlDecode(input: string): string
38
+ /**
39
+ * Compute the RFC 6455 `Sec-WebSocket-Accept` value for a client key.
40
+ * @param key the base64 `Sec-WebSocket-Key` header value.
41
+ * @returns the 28-byte base64 accept value.
42
+ */
43
+ wsAcceptKey(key: string): string
44
+ /** @param input an email address. @returns whether it is RFC-valid. */
45
+ validateEmail(input: string): boolean
46
+ /** @param input a UUID string. @returns whether it is a valid (v1-v8) UUID. */
47
+ validateUuid(input: string): boolean
48
+ /** @param input an IPv4 address. @returns whether it is valid. */
49
+ validateIpv4(input: string): boolean
50
+ /** @param input an IPv6 address. @returns whether it is valid. */
51
+ validateIpv6(input: string): boolean
52
+ }
53
+
54
+ /** Build the `text` namespace for a client context. */
55
+ export function buildText(ctx: RustClientContext): RustText {
56
+ const { builtins, transport } = ctx.runtime
57
+ // String memo: the byte path already caches the native bytes; this avoids the
58
+ // encode → native lookup → slice → decode round-trip for repeated extension
59
+ // strings (the common case) — 0 allocations on a cache hit.
60
+ const mimeStrCache = new Map<string, string>()
61
+
62
+ return {
63
+ mimeFromExtension(ext) {
64
+ let mime = mimeStrCache.get(ext)
65
+ if (mime === undefined) {
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)))
73
+ mimeStrCache.set(ext, mime)
74
+ }
75
+ return mime
76
+ },
77
+ urlEncode(input) {
78
+ // Bun's native encodeURIComponent beats the rust+FFI/NAPI string crossing
79
+ // (~4x, measured) with byte-for-byte parity (RFC 3986 unreserved set) —
80
+ // see docs/bun-builtins-decision-matrix.md.
81
+ if (builtins.has('urlEncode')) return builtins.urlEncodeStr(input)
82
+ return resolveNative(ctx, 'urlEncodeStr')(input) as string
83
+ },
84
+ urlDecode(input) {
85
+ // decodeURIComponent matches the rust decoder on valid input and also
86
+ // throws on malformed/invalid UTF-8 (URIError, an Error subclass).
87
+ if (builtins.has('urlDecode')) return builtins.urlDecodeStr(input)
88
+ return resolveNative(ctx, 'urlDecodeStr')(input) as string
89
+ },
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)
96
+ return decoder.decode(resolveNative(ctx, 'wsAcceptKey')(encoder.encode(key)) as Uint8Array)
97
+ },
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)
103
+ return resolveNative(ctx, 'validateEmail')(encoder.encode(input)) as boolean
104
+ },
105
+ validateUuid(input) {
106
+ const f = transport.ffi
107
+ if (f) return f.validateUuid(input)
108
+ return resolveNative(ctx, 'validateUuid')(encoder.encode(input)) as boolean
109
+ },
110
+ validateIpv4(input) {
111
+ const f = transport.ffi
112
+ if (f) return f.validateIpv4(input)
113
+ return resolveNative(ctx, 'validateIpv4')(encoder.encode(input)) as boolean
114
+ },
115
+ validateIpv6(input) {
116
+ const f = transport.ffi
117
+ if (f) return f.validateIpv6(input)
118
+ return resolveNative(ctx, 'validateIpv6')(encoder.encode(input)) as boolean
119
+ },
120
+ }
121
+ }