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,327 @@
1
+ # Writing Rust for Bun — FFI Optimization Guide
2
+
3
+ > **Who this is for**: anyone (human or agent) writing or reviewing the Rust
4
+ > `extern "C"` surface in `rust/ffi.rs` and its `bun:ffi` binding in
5
+ > `src/native/ffi.ts`. The rules below are the concrete, citable behaviors of
6
+ > Bun's own `bun:ffi` implementation, distilled so that future generated Rust
7
+ > code is **optimized for Bun** out of the box — correct on the first `dlopen`,
8
+ > fast on the first call, and safe under burst load.
9
+ >
10
+ > **Version anchor**: behavior claims are verified against the **Rust-based Bun
11
+ > 1.3.14** source (`/home/adeel/poc/bun`). `bun:ffi` is experimental upstream;
12
+ > the bind-time self-test + napi fallback are what make it safe to use, and
13
+ > they are mandatory — not optional.
14
+
15
+ ---
16
+
17
+ ## 1. How `bun:ffi` actually works (the call path)
18
+
19
+ `bun:ffi` is implemented natively in Bun's JavaScriptCore integration, not as a
20
+ JS shim:
21
+
22
+ - `dlopen(path, { symbols })` resolves each symbol via `dlsym`-style lookup and
23
+ creates a `JSFFIFunction` for it — a JS function that **holds the raw C
24
+ function pointer** and marshals arguments in-engine (`src/js/bun/ffi.ts`,
25
+ `src/runtime/ffi/ffi_body.rs::FFI::open`).
26
+ - **Hot call sites compile through the DFG/FTL JIT tiers into direct native
27
+ calls** — "argument conversion, arity handling, and result boxing happen
28
+ in-engine … with no per-argument JavaScript shim" (`docs/runtime/ffi.mdx`,
29
+ "Performance"). This is why the crossing is ~10–20 ns vs ~100–350 ns for a
30
+ Node-API call, and why Bun's own bench puts `bun:ffi` at 2–6× faster than
31
+ Node's N-API (`bench/ffi/bun.js`, `docs/runtime/ffi.mdx`).
32
+ - The `#[no_mangle] extern "C"` symbols come from the **same cdylib** that
33
+ napi-rs loads under Node — the two transports are two views of one binary.
34
+
35
+ **Implications for Rust code:**
36
+
37
+ - Keep `extern "C"` signatures **scalar + pointer/length**. Every argument is a
38
+ register-passable integer, float, or pointer — no variadics, no variadic
39
+ marshaling, no by-value structs (see §10).
40
+ - The ABI spec declared in the `dlopen` map (e.g. `["ptr","usize","u32"]`) MUST
41
+ match the Rust signature exactly — there is no runtime checking beyond the
42
+ bind-time self-test. A mismatch is a crash, not an error.
43
+
44
+ ---
45
+
46
+ ## 2. The ABI type system (`FFIType`)
47
+
48
+ The numeric tags are pinned by static asserts in
49
+ `src/jsc/bindings/JSCFFIBridge.cpp` (do not rely on the numbers; rely on the
50
+ string names). Canonical names used by castrum's `dlopen` map:
51
+
52
+ | Rust type | `bun:ffi` name | Notes |
53
+ |-----------------|-------------------|-------|
54
+ | `u8` | `"u8"` | returns as JS `number` |
55
+ | `u32` | `"u32"` | returns as JS `number` |
56
+ | `usize` / `u64` | `"usize"`/`"u64"` | `usize == u64` on the host ABI; returns surface as **BigInt** unless `u64_fast` |
57
+ | `i64` | `"i64"` | args passed as `BigInt(...)`; returns as BigInt |
58
+ | `f64` | `"f64"` | e.g. `castrum_http_date_into(secs: f64, …)` |
59
+ | `*const u8` | `"ptr"` | TypedArray/DataView auto-converted to its pointer; a plain `number` is passed through |
60
+ | `*const u8` (NUL-terminated string) | `"cstring"` | args: JS string transcoded to a call-scoped NUL-terminated UTF-8 buffer (zero JS-side encode — the engine does it); returns: cloned JS string. **ARGS are the fast string-input path** (see §3 shape 4) |
61
+ | `*const u8` + length as a pair | `"buffer"` + `"buffer_length"` | see §4 |
62
+ | `void` | `"void"` | — |
63
+ | — | `"u64_fast"` / `"i64_fast"` | unboxed `number` when the value fits in a double (< 2^53); no BigInt boxing |
64
+
65
+ > Full table (incl. `i8..i32`, `f32`, `bool`, `char`, `function`, `napi_env`,
66
+ > `napi_value`) is in `docs/runtime/ffi.mdx` → "FFI types" and
67
+ > `packages/bun-types/ffi.d.ts`.
68
+
69
+ **Rule**: for any return that is a byte count / length / small scalar, use
70
+ `u64_fast` in the binding (castrum's `U64_FAST` constant) so the hot path never
71
+ boxes a BigInt. Keep the Rust return type as `usize`/`u32` — only the JS binding
72
+ changes.
73
+
74
+ ---
75
+
76
+ ## 3. Canonical C-ABI signature shapes (use these)
77
+
78
+ Everything castrum exports follows one of four shapes. New exports should pick
79
+ the matching shape and nothing else:
80
+
81
+ 1. **`(ptr, len) -> scalar`** — pure read of input, no output buffer:
82
+ `pub unsafe extern "C" fn castrum_crc32(data: *const u8, len: usize) -> u32`.
83
+ Bind as `{ args: inputAbi, returns: 'u32' }` (`inputAbi` = the
84
+ `buffer`/`buffer_length` pair or `(ptr,usize)`, see §4).
85
+ 2. **`(ptr, len, out, out_cap) -> usize`** — output-into-caller-buffer with the
86
+ **needed-size convention** (§12): return the exact required byte count on a
87
+ too-small buffer, `0` = real error. Bind with `abi(['ptr','usize','ptr','usize'])`
88
+ and `returns: U64_FAST`, drive through `growExact`.
89
+ 3. **`(…) -> *const c_char`** — cstring return (per-thread reused `CSTR_BUF`,
90
+ §7). Only for genuinely string-shaped results; prefer packed buffers for
91
+ structured data.
92
+ 4. **`cstring` ARG for string inputs** — `f(s: *const c_char) -> …`. When the
93
+ input is TEXT (never NUL) and the JS caller has it as a JS `string`, pass it
94
+ as a `cstring` ARG instead of a `(ptr,len)` pair: the engine transcodes the
95
+ string to a call-scoped NUL-terminated UTF-8 buffer in-engine (zero JS-side
96
+ `encoder.encode`), and the callee borrows via `CStr::from_ptr(s).to_bytes()`
97
+ (no copy). **Benchmark-gated** (`bench:margin` `cstringArg` scenario):
98
+ ~40–52 ns/call vs ~209–233 ns for `encodeUtf8` + `(ptr,len)` — a ~76–82% win,
99
+ including non-ASCII inputs. It is a LOSS for BYTE inputs (decode + re-encode)
100
+ — keep `(ptr,len)` there. **Verified `bun:ffi` facts**: JS `null` → NULL
101
+ pointer, and JS `''` (empty string) → NULL too — a present-but-empty value is
102
+ indistinguishable from absent through a `cstring` arg (so SSE's `Option`
103
+ event/id semantics stay on `(ptr,len)`). Adopted by: the validators
104
+ (`castrum_validate_*`), `castrum_ws_accept_key`, `castrum_mime_from_extension`,
105
+ `castrum_password_verify_bcrypt` (phc), `castrum_rate_limiter_check` (key),
106
+ and `castrum_ingress_handle_components` (url/ip).
107
+
108
+ Opaque instance handles are passed as a **bare `usize`** (`inner: usize`), and
109
+ must be null-checked at the top of the function (return the `0` sentinel) —
110
+ castrum's `castrum_ingress_handle_packed` / `castrum_*_matcher_matches` etc.
111
+
112
+ ---
113
+
114
+ ## 4. `buffer` / `buffer_length` — the atomic snapshot pair
115
+
116
+ `FFIType.buffer` (20) and `FFIType.buffer_length` (21) are a paired ABI: pass
117
+ the **same** TypedArray/DataView for both, and the callee receives the pointer
118
+ and the **byte length** of that same view. The engine reads ptr + byteLength
119
+ off the same object **at call time** — an atomic snapshot you cannot get by
120
+ passing `view.byteLength` yourself (a JS-side length read can go stale against
121
+ a resizable/growable/transferred buffer). Source:
122
+ `packages/bun-types/ffi.d.ts` (`buffer_length` doc), `docs/runtime/ffi.mdx`.
123
+
124
+ - **Argument-only**: valid for `dlopen`/`linkSymbols`/`CFunction` args, NOT for
125
+ `cc()` or `JSCallback` (`src/runtime/ffi/ffi_body.rs::reject_cc_unsupported_types_error`).
126
+ - **castrum's pattern** (`src/native/ffi.ts`): `probeBufferLength()` verifies
127
+ the pair at bind time (an older Bun canary threw "invalid ABI type" for it);
128
+ when supported, the `abi()` transformer rewrites every `(ptr,usize)` pair to
129
+ `(buffer,buffer_length)` and call sites pass the view twice (`lenOrView`).
130
+ When not supported, explicit `(view, view.length)` is used. The probe makes
131
+ the fast path the default on every modern Bun (1.3.x+) with zero drift.
132
+
133
+ **Rule**: keep the probe. Never hardcode the pair as the only path — a future
134
+ Bun could remove it, and the probe + `(ptr,usize)` fallback is what keeps the
135
+ transport working.
136
+
137
+ ---
138
+
139
+ ## 5. Pointers are `number`, not `BigInt`
140
+
141
+ Bun represents pointers as a JS **`number`** (`type Pointer = number` in
142
+ `packages/bun-types/ffi.d.ts`). 64-bit processors address ≤52 bits, which fits
143
+ in a double's 53-bit mantissa. Passing a `BigInt` to an FFI arg is converted to
144
+ a `number` anyway (`docs/runtime/ffi.mdx` → "Pointers"). Windows HANDLE values
145
+ are the exception — use `u64` for those.
146
+
147
+ - Get a pointer from a TypedArray with `ptr(view)`; read back with
148
+ `read.*` (fast, no DataView/ArrayBuffer alloc) or `toArrayBuffer`/`toBuffer`.
149
+ - **`u64`/`i64` returns** surface as BigInt (boxed) unless you bind
150
+ `u64_fast`/`i64_fast` (§2).
151
+ - **castrum's application**: the ingress opaque handle is obtained from napi as
152
+ a BigInt and converted to a number ONCE (`Number(handler.ingressInnerPtr())`
153
+ in `src/ingress/fast.ts` / `handlers.ts`), then passed as a `usize` arg —
154
+ number is cheaper than BigInt on the hot path.
155
+
156
+ **Rule**: pass opaque handles as `usize`/number; convert the napi BigInt once,
157
+ not per call. If a function could ever receive a value ≥ 2^53, it cannot be a
158
+ pointer — rethink the ABI.
159
+
160
+ ---
161
+
162
+ ## 6. `cstring` returns are cloned at call time
163
+
164
+ When a symbol's return type is `cstring`, Bun clones the NUL-terminated C
165
+ string into a JS `string` at call time and returns that; `NULL` → `null`
166
+ (`src/js/bun/ffi.ts` `FFIBuilder`, `docs/runtime/ffi.mdx` → "Strings" +
167
+ "engine-native cstring" tests in `test/js/bun/ffi/ffi.test.js`). The engine
168
+ copies **nothing itself** — the C side must own the memory until the call
169
+ returns.
170
+
171
+ - **castrum's application**: the per-thread reused `CSTR_BUF` in `rust/ffi.rs`
172
+ (`cstring_return`) is exactly right — the clone is synchronous, so reusing
173
+ the buffer across calls is safe.
174
+ - **Aliasing caveat**: a C function that returns a pointer *derived from a
175
+ `cstring` argument* you passed as a JS string — that argument was transcoded
176
+ into an engine call-scoped buffer — is valid only until your **next** FFI
177
+ call reuses that buffer. Clone it rather than holding the raw address. Avoid
178
+ returning pointers into argument memory.
179
+
180
+ **Rule**: use per-thread reused buffers for cstring returns; never return a
181
+ pointer into JS-owned memory; treat `null` as the failure sentinel.
182
+
183
+ ---
184
+
185
+ ## 7. `dlopen` lifetime — never `close()` while bound symbols live
186
+
187
+ Bun deliberately **leaks** a `dlopen`'d library if you never call `close()`:
188
+ "dlclose on GC is unsound because `.ptr` addresses escape the collector's view"
189
+ (`src/runtime/ffi/ffi_body.rs` — `FFI::finalize`). Each `JSFFIFunction` also
190
+ holds a reference to its owning FFI object so the dylib stays alive as long as
191
+ any bound function is reachable.
192
+
193
+ - **castrum's application**: `getBunFFI()` binds once, holds the symbols for
194
+ the process lifetime, and never calls `close()`. That is the correct pattern
195
+ — do not "clean up" by closing a live library. Closing is only for short-lived
196
+ ad-hoc libraries (e.g. a `dlopen` in a test), and only after no bound
197
+ function can be called.
198
+
199
+ **Rule**: bind once, hold forever. If you ever call `close()`, it must be after
200
+ all bound symbols are unreachable.
201
+
202
+ ---
203
+
204
+ ## 8. Thread-safety & per-thread caches
205
+
206
+ `bun:ffi` symbols are shared across Worker threads (same dlopen'd cdylib
207
+ mapping; verified by castrum's `bench/ffi-workers.ts` — 4 workers, no
208
+ thread-safety issue). The Rust side must therefore keep any per-call mutable
209
+ state **thread-local**, never global:
210
+
211
+ - `HMAC_KEY_CACHE` (`thread_local! RefCell<LruCache<Vec<u8>, hmac::Key>>`) —
212
+ compiled-key cache with zero lock contention across threads.
213
+ - `CSTR_BUF` (`thread_local! Vec<u8>`) — cstring return buffer.
214
+
215
+ **Rule**: no `static mut`, no shared mutable global in the C-ABI path. Use
216
+ `thread_local!` for per-thread scratch/caches, or `OnceLock` for immutable
217
+ one-time state. Never hold a `Mutex` across an FFI call.
218
+
219
+ ---
220
+
221
+ ## 9. There is no struct-by-value — use packed length-prefixed buffers
222
+
223
+ `bun:ffi`'s FFIType set has **no struct member** (only scalars, `ptr`,
224
+ `cstring`, `buffer`, `function`, and the napi types). You cannot pass a JS
225
+ object as a C struct argument, and a Rust `#[repr(C)]` struct is not directly
226
+ marshalable. This is why castrum's wire format is **packed, length-prefixed
227
+ byte buffers**:
228
+
229
+ - Inputs are packed by JS (`IngressInputPacker`, `pack_headers`, packed
230
+ parsers) into a `Uint8Array` and passed `(ptr, len)`.
231
+ - Outputs are written by Rust into a caller buffer as
232
+ `[u32 count][u32 field_len][field bytes]…` (or fixed-slot layouts like the
233
+ `IngressLayout` 38×`u32` blob / rate-limit verdict `[u8][u32][i64]`).
234
+
235
+ This also enables zero-copy sharing and buffer pooling — a struct-by-value ABI
236
+ would have forced per-call copies.
237
+
238
+ **Rule**: for any multi-field result, define a packed byte layout (fixed slots
239
+ or length-prefixed sections), declare it in ONE Rust place (e.g.
240
+ `rust/ingress/output.rs`), mirror it in JS (`src/ingress/constants.ts`), and pin
241
+ it with a unit test + bind-time self-test. **Alignment**: keep 8-byte scalar
242
+ fields (e.g. the `i64 reset_ms`) aligned to 8 in the layout so Rust reads are
243
+ unclipped and DataView reads are natural.
244
+
245
+ ---
246
+
247
+ ## 10. Callbacks (`JSCallback`) — use sparingly, never in hot paths
248
+
249
+ `JSCallback` lets native code call back into JS. A `threadsafe` callback copies
250
+ the C args on the calling thread and **marshals the invocation onto the JS
251
+ thread** — from C's point of view it is asynchronous, and the value returned to
252
+ C is unspecified (treat it as `void`) (`docs/runtime/ffi.mdx` → "Callbacks",
253
+ `src/jsc/bindings/JSCFFIBridge.cpp::Bun__jscFFITimeadDispatch`). Async functions
254
+ are not supported. Each crossing has marshaling + event-loop cost.
255
+
256
+ **Rule**: prefer a synchronous return over a callback. If a callback is
257
+ unavoidable (push/event-driven integrations), pass `JSCallback.prototype.ptr`
258
+ directly (slightly faster than the object), declare `returns: "void"`-ish on the
259
+ C side, and call `close()` when done. castrum's hot request path uses **zero**
260
+ callbacks — keep it that way.
261
+
262
+ ---
263
+
264
+ ## 11. Mandatory rules for every new Rust export
265
+
266
+ These are not optional — they are what make the C-ABI surface safe on Bun (no
267
+ napi-style unwind guard exists for raw `extern "C"`).
268
+
269
+ 1. **`panic_guard` every fallible/allocating core** (`std::panic::catch_unwind`
270
+ in `rust/ffi.rs`, `AssertUnwindSafe` for captured `&mut [u8]`). An uncaught
271
+ panic unwinding through `extern "C"` kills the whole Bun process — this is
272
+ what happened under `11-concurrent-burst`.
273
+ 2. **Bind-time self-test for EVERY new symbol** (`src/native/ffi/selfTest`).
274
+ `bun:ffi` is experimental upstream; the self-test + napi fallback is the
275
+ safety net. No symbol ships without a self-test vector.
276
+ 3. **Needed-size convention** for variable-size outputs: return the EXACT
277
+ required byte count on a too-small buffer; `0` stays a REAL error. JS uses
278
+ `growExact` (allocate once, retry once — never a doubling re-run).
279
+ 4. **Null-check every pointer arg** (`if data.is_null() && len != 0 { return 0 }`)
280
+ and every opaque handle (`inner == 0` → sentinel).
281
+ 5. **`u64_fast` byte-count returns** so the JS side avoids BigInt boxing.
282
+ 6. **Thread-local scratch** — no global mutable state (§8).
283
+ 7. **Parity**: add the symbol to the `dlopen` map, `ffi/types.ts` (`BunFFI`),
284
+ the scalar builder, and `ffi-symbol-parity.test.ts`; a Rust unit test +
285
+ self-test; cross-check FFI vs napi byte-for-byte in `ffi.test.ts`.
286
+
287
+ ---
288
+
289
+ ## 12. Bun's own Rust hygiene (borrowed rules)
290
+
291
+ Bun's Rust codebase (`/home/adeel/poc/bun`, AGENTS.md) enforces conventions
292
+ that transfer directly to castrum's cdylib:
293
+
294
+ - **RAII / `Drop` over manual cleanup**; don't leak or free twice across the
295
+ FFI boundary.
296
+ - **No mutable `#[no_mangle]` global state** without a synchronization
297
+ wrapper. Bun wraps exported statics in `RacyCell` (see
298
+ `src/jsc/FFI.rs`: `#[unsafe(no_mangle)] static ValueUndefined: RacyCell<…>`)
299
+ so the symbol bytes stay identical while satisfying `Sync`. castrum's
300
+ `thread_local!` caches and `OnceLock` state follow the same spirit.
301
+ - **`#[unsafe(no_mangle)]`** (Rust 2024) or `#[no_mangle]` (Rust 2021) on every
302
+ export, `pub unsafe extern "C"`, snake_case names, `///` doc comments.
303
+ - Keep napi types OUT of internal signatures so core logic stays unit-testable
304
+ in pure Rust (`#[cfg(test)] mod tests` in the same file).
305
+
306
+ ---
307
+
308
+ ## 13. Version anchors & verification
309
+
310
+ | Claim | Source (Bun 1.3.14 checkout) |
311
+ |---|---|
312
+ | JIT-to-direct-native-call, 2–6× vs NAPI | `docs/runtime/ffi.mdx` → Performance; `bench/ffi/bun.js` |
313
+ | `FFIType` numeric tags (`buffer`=20, `buffer_length`=21, `u64_fast`=16) | `src/js/bun/ffi.ts`; static asserts in `src/jsc/bindings/JSCFFIBridge.cpp` |
314
+ | `buffer`/`buffer_length` atomic snapshot, arg-only | `packages/bun-types/ffi.d.ts`; `src/runtime/ffi/ffi_body.rs::reject_cc_unsupported_types_error` |
315
+ | Pointers are `number`; BigInt converted to number | `docs/runtime/ffi.mdx` → Pointers |
316
+ | cstring returns cloned; NULL → null | `src/js/bun/ffi.ts` `FFIBuilder`; `test/js/bun/ffi/ffi.test.js` |
317
+ | dlopen leaks unless `close()`; unsound dlclose-on-GC | `src/runtime/ffi/ffi_body.rs` `FFI::finalize` |
318
+ | Low-overhead C-fn call (TinyCC path), zero bounds checking | `src/jsc/bindings/JSFFIFunction.h` |
319
+
320
+ **Verify after any FFI change**:
321
+
322
+ ```bash
323
+ nm -D --defined-only <addon> | grep -c castrum_ # = 79
324
+ bun run bench:http:smoke # after touching decoders/handlers
325
+ bun test test/unit/features/ffi.test.ts # FFI↔napi parity + self-test
326
+ bun test test/unit/features/ffi-symbol-parity.test.ts
327
+ ```
@@ -0,0 +1,192 @@
1
+ # Getting Started with castrum
2
+
3
+ A walkthrough for a first-time contributor or intern. By the end you'll know
4
+ how to import the package, use the `rust.*` primitives, stand up an HTTP
5
+ server, run the benchmarks, and run the test suite. For the full file map see
6
+ [`REPO_MAP.md`](./REPO_MAP.md).
7
+
8
+ ## Prerequisites
9
+
10
+ - [Bun](https://bun.sh) ≥ 1.1 (primary runtime)
11
+ - [Rust](https://rustup.rs) — stable + `cargo` (to build/test the addon). The
12
+ repo pins a toolchain in `rust-toolchain.toml` (`channel = "1.97.1"`); install
13
+ it with `rustup` so local builds match CI.
14
+ - Node.js ≥ 20.3 (optional — only for the Node compatibility path)
15
+
16
+ > **Building on different OSes**: `bun run build` (napi) works on Linux, macOS,
17
+ > and Windows (WSL recommended on Windows). `bun run build:perf` shells out to
18
+ > `bash scripts/build-perf.sh` and is **Linux-only** — it is a local max-perf
19
+ > build, never for publish. If `bash` is unavailable, use plain `bun run build`.
20
+
21
+ ```bash
22
+ bun install
23
+ ```
24
+
25
+ ## 1. Import the package
26
+
27
+ The entry point is `index.ts` (Bun resolves it via the `bun` export condition).
28
+ Node users get the compiled `dist/index.js`.
29
+
30
+ ```ts
31
+ import { rust } from "castrum";
32
+ ```
33
+
34
+ ## 2. Use the Rust-accelerated primitives
35
+
36
+ The flat `rust.*` namespace wraps every native function:
37
+
38
+ ```ts
39
+ import { rust } from "castrum";
40
+
41
+ rust.crc32(new TextEncoder().encode("hello")); // number
42
+ rust.fnv1a64(bytes); // bigint
43
+ rust.jsonValid(bytes); // boolean
44
+ rust.validateEmail(emailBytes); // boolean
45
+
46
+ // Text helpers (string in / string out)
47
+ rust.text.mimeFromExtension(".js"); // "text/javascript"
48
+ rust.text.urlEncode("a b"); // "a%20b"
49
+
50
+ // Batch helpers (array of byte arrays → packed bitset/count)
51
+ rust.batch.jsonValid([docA, docB, docC]); // Uint8Array bitset
52
+ rust.batch.fnv1a64([a, b]); // BigUint64Array
53
+ rust.batch.etag([a, b]); // Uint8Array[]
54
+ ```
55
+
56
+ Most functions accept `Uint8Array` (or strings via the `text.` namespace).
57
+ Everything is **synchronous** — there is no async/await in the FFI surface.
58
+
59
+ > **Transport**: under Bun these calls run through `bun:ffi` (the PRIMARY
60
+ > transport — see `rust.transport()` / `rust.ffiActive()`); Node and any
61
+ > `CASTRUM_FFI_MODE=napi` fall back to the napi addon. Since Phase 6 the
62
+ > compiled-once instances are NOT napi-only — their hot per-call ops route
63
+ > through the C-ABI via opaque-`usize` handles. Still napi-only:
64
+ > `rust.batch.*` / `rust.packed.*`, `text.*`, and the DOM `jsonParse`.
65
+
66
+ > **Tip**: `rust` is a singleton created with sensible defaults. If you need
67
+ > isolated state (own rayon threads, own HMAC cache), call
68
+ > `createRust({ rayonThreads: 4 })`.
69
+
70
+ ### Higher-order data loader (`loader`)
71
+
72
+ For workloads that call the same op many times, `loader` gives you a hot,
73
+ pre-bound function with automatic bulk-vs-scalar dispatch, DataLoader-style
74
+ coalescing and a bounded LRU cache — over **44 ops** including paired and
75
+ schema-validator ops:
76
+
77
+ ```ts
78
+ import { loader } from "castrum";
79
+
80
+ const isEmail = loader("validateEmail");
81
+ isEmail(emailBytes); // scalar → boolean
82
+ isEmail([a, b, c]); // bulk → ONE packed call
83
+ await isEmail.load(a); // coalesced + cached
84
+
85
+ loader.run("jsonPatch", docs, patches); // paired ops (companion array)
86
+ loader.schema(validator)(docs); // schema bitset (+ .count())
87
+ ```
88
+
89
+ See the README "Higher-order data loader" section for the full op table and
90
+ `createLoader` options.
91
+
92
+ ## 3. Stand up an HTTP server (the ingress pipeline)
93
+
94
+ The quickest path is the **pre-baked** route handlers + the server builder:
95
+
96
+ ```ts
97
+ import { createIngressHandler, createIngressServer } from "castrum";
98
+
99
+ const ingress = createIngressHandler({
100
+ parseCookies: true,
101
+ parseQuery: true,
102
+ cors: { allowOrigin: ["https://app.example.com"] },
103
+ });
104
+
105
+ // createIngressServer is SYNCHRONOUS — it wraps Bun.serve over the routes.
106
+ // Each route maps HTTP methods to the pre-baked handlers (see below):
107
+ const server = createIngressServer({
108
+ port: 3000,
109
+ routes: {
110
+ "/health": { read: ingress }, // GET + HEAD
111
+ "/api/users": { read: ingress, write: ingress }, // + POST/PUT/PATCH (+OPTIONS)
112
+ },
113
+ });
114
+
115
+ console.log("listening on", server.port);
116
+ ```
117
+
118
+ - `read: ingress` — GET (+ HEAD) returns the ingress body JSON.
119
+ - `write: ingress` — POST/PUT/PATCH validates + echoes JSON.
120
+ - `echo: ingress`, `cookies: ingress`, and a top-level `fallback` are the other
121
+ route options. The underlying factories (`readHandler`, `jsonWriteHandler`,
122
+ `echoHandler`, `headHandler`, `fallbackHandler`) are also exported directly
123
+ if you'd rather wire methods by hand.
124
+ - **Runtime-adaptive**: `createIngressServer` picks `Bun.serve` on Bun and
125
+ `node:http` on Node; `createIngressServerNode` is the explicit Node adapter
126
+ with the same `routes` spec (and `await srv.ready` for the port).
127
+
128
+ The wire format is `{"ok":true,...,"requestId":...}` on success and
129
+ `{"ok":false,"error":{code,message}}` on errors, with `ratelimit-*` headers.
130
+
131
+ > There is also a lower-level **fast path** (`createIngressFast` +
132
+ > `createIngressSync`/`createIngress`). Prefer it only when you need the
133
+ > absolute lowest overhead and are comfortable calling `run()` yourself with a
134
+ > synchronous callback.
135
+
136
+ ## 4. Run the benchmarks
137
+
138
+ ```bash
139
+ # CPU benchmark (correctness checks + comparisons)
140
+ bun run check
141
+
142
+ # HTTP benchmark across all servers (bun / elysia / ingress)
143
+ bun run bench:http
144
+
145
+ # Fast HTTP smoke (the CI wire-format guard)
146
+ bun run bench:http:smoke
147
+ ```
148
+
149
+ Results land in `bench/results/` (gitignored).
150
+
151
+ ## 5. Run the tests
152
+
153
+ ```bash
154
+ # Everything at once (TS unit + Rust unit + typecheck + lint)
155
+ bun run test:all
156
+
157
+ bun test # TypeScript unit tests (test/unit/**)
158
+ bun run test:rust # Rust unit tests (cargo test)
159
+ bun run typecheck # TypeScript typecheck
160
+ bun run lint:ci # Biome lint gate (what CI enforces)
161
+ bun run test:node # Node.js integration tests (node --test) — run `bun run build:js` first
162
+ ```
163
+
164
+ ## 6. Make your first change
165
+
166
+ 1. Find where the code lives with [`REPO_MAP.md`](./REPO_MAP.md).
167
+ 2. Edit the TypeScript (`src/`) and/or Rust (`rust/`).
168
+ 3. If you changed Rust, rebuild the addon: `bun run build:debug`.
169
+ 4. Run `bun test`, `bun run typecheck`, and `cargo test`.
170
+ 5. If you touched any server or `handlers.ts`, run `bun run bench:http:smoke`.
171
+ 6. Format + lint: `bun run format` and `bun run lint`.
172
+
173
+ ## Common gotchas
174
+
175
+ - The **two ingress paths** have different wire formats — don't unify them
176
+ (see REPO_MAP §4.2 and AGENTS.md).
177
+ - `tsconfig.json` typechecks `index.ts`, `bench.ts`, and `src/` (so `bench/` IS
178
+ covered by `bun run typecheck`); `test/` + `bench/` are covered by
179
+ `bun run typecheck:test` (`-p tsconfig.test.json`).
180
+ - `src/ingress/constants.ts` dlopens the addon at import time — that's
181
+ intentional.
182
+ - The native addon must be rebuilt (`bun run build:debug`) before tests that
183
+ touch Rust changes will see them.
184
+
185
+ ## Next steps
186
+
187
+ - [`API.md`](./API.md) — the complete public API reference
188
+ - [`INGRESS.md`](./INGRESS.md) — the full pre-baked ingress API
189
+ - [`CASE_STUDY.md`](./CASE_STUDY.md) — the story behind the numbers (how castrum was built)
190
+ - [`REPO_MAP.md`](./REPO_MAP.md) — where everything is and how it connects
191
+ - [`ARCHITECTURE.md`](./ARCHITECTURE.md) — deep-dive internals
192
+ - [`AGENTS.md`](../AGENTS.md) — agent/contributor guidance
@@ -0,0 +1,81 @@
1
+ # `createIngressRouter` — per-route compiled ingress
2
+
3
+ `createIngressRouter` (`src/ingress/router.ts`) compiles a **route table into
4
+ dedicated native ingress instances** — one `IngressInner` per route, pruned to
5
+ exactly that route's stages. It is the "one super solution" over the existing
6
+ ingress core for servers with heterogeneous routes.
7
+
8
+ ## Why per-route compilation
9
+
10
+ The plain `createIngressHandler` (see [`INGRESS.md`](./INGRESS.md)) serves every
11
+ path from ONE global `IngressInner` configured with a **superset** of options. A
12
+ router compiles each route's `options` into its OWN handler:
13
+
14
+ - The native pipeline prunes to that route's stages (`parseCookies`,
15
+ `parseQuery`, `schema`, `cors`, `rateLimit`, `limits`, `requireJsonBody`…).
16
+ - `buildHeaderPlan` produces a **per-route header plan** — routes that need no
17
+ headers gather ZERO headers.
18
+ - All routes share the SAME wire format (the existing packed
19
+ `handle_request_packed`), the shared process-wide rate-limiter budgets, and
20
+ optional `warmOnCreate` pre-warming.
21
+
22
+ The per-route native-compile idea is ALSO exposed standalone as the native route
23
+ stack (`rust/ingress/native_route.rs` + the `castrum_route_*` / napi `Route`
24
+ surface consumed by `@ignex/native`) — see [`NATIVE-ROUTE.md`](./NATIVE-ROUTE.md).
25
+
26
+ ## API
27
+
28
+ ### `RouterRouteSpec`
29
+
30
+ Per-route spec: `options` (per-route `IngressHandlerOptions`), plus flags that
31
+ wire route factories over that route's compiled handler:
32
+
33
+ | Flag | Routes wired |
34
+ |------|--------------|
35
+ | `read` | GET + HEAD read handler |
36
+ | `write` | POST/PUT/PATCH JSON-write handler |
37
+ | `echo` | POST echo handler |
38
+ | `cookies` | GET read (cookies-style) handler |
39
+ | `delete` | DELETE read-style handler |
40
+ | `responder` | JS responder route (`methods`, `readBody`, `terminalStyle`) |
41
+ | `raw` | Raw `Request → Response` handler served OUTSIDE the pipeline (health/metrics probes) |
42
+ | `maxBodyBytes` / `bodyTimeoutMs` | Per-route write/echo overrides |
43
+
44
+ ### `createIngressRouter(options)` → `IngressRouter`
45
+
46
+ - `routes: Record<string, RouterRouteSpec>` — the route table (required).
47
+ - `runtime?: BakedIngressRuntime` — shared hooks applied to every compiled route.
48
+ - `getIp`, `copyBody`, `terminalStyle` — shared defaults.
49
+ - `warmOnCreate?: boolean` — pre-warm every compiled route at construction.
50
+
51
+ `IngressRouter` exposes:
52
+
53
+ - `routeHandlers` — per-path compiled handlers.
54
+ - `routes` — path → method → handler map (compatible with `Bun.serve({ routes })`
55
+ and `createIngressServerNode`).
56
+ - `match(pathname)` — path matcher (`:param` / `*` dynamic routes).
57
+ - `fetch(req, srv?)` — fetch-style dispatcher.
58
+ - `prewarm()` — JIT-warm all compiled routes.
59
+
60
+ ## Example
61
+
62
+ ```ts
63
+ const router = createIngressRouter({
64
+ warmOnCreate: true,
65
+ runtime: ingressMetrics?.runtime,
66
+ routes: {
67
+ '/health': { read: true, options: { parseCookies: false, parseQuery: false } },
68
+ '/api/users': {
69
+ read: true,
70
+ write: true,
71
+ options: { parseCookies: true, parseQuery: true, schema: USER_SCHEMA_BYTES },
72
+ },
73
+ '/metrics': { raw: metricsHandler(ingressMetrics) },
74
+ },
75
+ })
76
+ const server = Bun.serve({ routes: router.routes }) // or router.fetch
77
+ ```
78
+
79
+ The benchmark server `bench/servers/router-server.ts` uses this API (same route
80
+ surface as `ingress-server.ts` but per-route compiled). Cost breakdown:
81
+ `bun bench/router-cost.ts` (`bun run bench:router`).