castrum 0.9.0 → 0.9.2

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 (120) hide show
  1. package/README.md +153 -630
  2. package/castrum.linux-x64-gnu.node +0 -0
  3. package/dist/index.d.ts +1653 -1817
  4. package/dist/index.js +6172 -4294
  5. package/docs/API.md +337 -0
  6. package/docs/ARCHITECTURE.md +111 -17
  7. package/docs/BENCHMARKS.md +117 -49
  8. package/docs/CASE_STUDY.md +243 -0
  9. package/docs/ENVIRONMENT.md +24 -7
  10. package/docs/FFI_BUN_GUIDE.md +335 -0
  11. package/docs/GETTING_STARTED.md +18 -34
  12. package/docs/HOW_TO_ADD_AN_OP.md +180 -0
  13. package/docs/INGRESS-ROUTER.md +82 -0
  14. package/docs/INGRESS.md +11 -7
  15. package/docs/NATIVE-ROUTE.md +77 -0
  16. package/docs/REPO_MAP.md +103 -53
  17. package/docs/adr/0001-two-wire-formats.md +1 -1
  18. package/docs/adr/0003-bun-builtins-delegation.md +6 -5
  19. package/docs/ai/LOCAL_DEV.md +81 -0
  20. package/docs/ai/TREE.md +1070 -0
  21. package/docs/bun-builtins-decision-matrix.md +25 -18
  22. package/index.ts +9 -16
  23. package/package.json +41 -28
  24. package/src/ingress/body.ts +167 -27
  25. package/src/ingress/decode/result-base.ts +10 -2
  26. package/src/ingress/handlers.ts +116 -285
  27. package/src/ingress/headers/security.ts +41 -0
  28. package/src/ingress/health.ts +1 -0
  29. package/src/ingress/index.ts +45 -169
  30. package/src/ingress/native-route.ts +182 -0
  31. package/src/ingress/packing/gather-raw-headers.ts +110 -22
  32. package/src/ingress/packing/header-packing.ts +5 -5
  33. package/src/ingress/packing/input-packer.ts +17 -3
  34. package/src/ingress/packing/route-wire.ts +197 -0
  35. package/src/ingress/packing/select-headers.ts +20 -7
  36. package/src/ingress/path-matcher.ts +115 -0
  37. package/src/ingress/response/baked-response.ts +302 -0
  38. package/src/ingress/router.ts +277 -0
  39. package/src/ingress/routes/common.ts +50 -1
  40. package/src/ingress/routes/fallback.ts +2 -2
  41. package/src/ingress/routes/head.ts +3 -13
  42. package/src/ingress/routes/index.ts +5 -4
  43. package/src/ingress/routes/json-write.ts +26 -47
  44. package/src/ingress/routes/native.ts +130 -0
  45. package/src/ingress/routes/options.ts +2 -12
  46. package/src/ingress/routes/read.ts +3 -13
  47. package/src/ingress/routes/responder.ts +159 -0
  48. package/src/ingress/server-node.ts +7 -7
  49. package/src/ingress/server.ts +83 -112
  50. package/src/ingress/sync.ts +151 -0
  51. package/src/ingress/types.ts +44 -1
  52. package/src/integration/pipeline.ts +3 -0
  53. package/src/integration/streaming.ts +2 -3
  54. package/src/integration/websocket.ts +4 -3
  55. package/src/loader/batch.ts +4 -0
  56. package/src/loader/cost.ts +1 -0
  57. package/src/loader/index.ts +43 -228
  58. package/src/loader/state.ts +84 -0
  59. package/src/loader/types.ts +145 -0
  60. package/src/native/ffi/build/codecs.ts +736 -0
  61. package/src/native/ffi/build/compress.ts +248 -0
  62. package/src/native/ffi/build/instances.ts +598 -0
  63. package/src/native/ffi/build/parse.ts +539 -0
  64. package/src/native/ffi/build/util.ts +140 -0
  65. package/src/native/ffi/build.ts +94 -0
  66. package/src/native/ffi/constants.ts +58 -0
  67. package/src/native/ffi/selftest.ts +22 -0
  68. package/src/native/ffi/types.ts +499 -0
  69. package/src/native/ffi.ts +307 -1357
  70. package/src/native/loader.ts +52 -6
  71. package/src/native/types/instances.ts +279 -0
  72. package/src/native/types.ts +41 -238
  73. package/src/runtime/builtins.ts +131 -0
  74. package/src/runtime/codec.ts +147 -0
  75. package/src/runtime/detect.ts +36 -0
  76. package/src/runtime/index.ts +28 -0
  77. package/src/runtime/native.ts +35 -0
  78. package/src/runtime/server.ts +31 -0
  79. package/src/runtime/transport.ts +64 -0
  80. package/src/runtime/types.ts +173 -0
  81. package/src/runtime/uuid.ts +45 -0
  82. package/src/runtime/websocket.ts +30 -0
  83. package/src/rust-ffi/addon.ts +1 -1
  84. package/src/rust-ffi/batch/build.ts +155 -0
  85. package/src/rust-ffi/batch/index.ts +8 -0
  86. package/src/rust-ffi/batch/types.ts +92 -0
  87. package/src/rust-ffi/client.ts +5 -6
  88. package/src/rust-ffi/context.ts +39 -10
  89. package/src/rust-ffi/index.ts +28 -17
  90. package/src/rust-ffi/into.ts +21 -0
  91. package/src/rust-ffi/options.ts +1 -1
  92. package/src/rust-ffi/packed.ts +40 -2
  93. package/src/rust-ffi/proven.ts +16 -30
  94. package/src/rust-ffi/scalar/crypto.ts +188 -107
  95. package/src/rust-ffi/scalar/factories.ts +257 -27
  96. package/src/rust-ffi/scalar/hashing.ts +43 -41
  97. package/src/rust-ffi/scalar/http.ts +208 -77
  98. package/src/rust-ffi/scalar/index.ts +3 -3
  99. package/src/rust-ffi/scalar/interface.ts +88 -213
  100. package/src/rust-ffi/scalar/json-packed.ts +175 -0
  101. package/src/rust-ffi/scalar/json.ts +28 -12
  102. package/src/rust-ffi/scalar/payload.ts +159 -42
  103. package/src/rust-ffi/text.ts +31 -5
  104. package/src/selection.ts +27 -31
  105. package/src/shared/adaptive.ts +1 -2
  106. package/src/shared/buffer-pool.ts +72 -17
  107. package/src/shared/bytes.ts +75 -6
  108. package/src/shared/codec.ts +78 -0
  109. package/src/shared/env.ts +4 -4
  110. package/src/shared/log.ts +1 -0
  111. package/src/shared/packed/index.ts +10 -0
  112. package/src/shared/packed/parsers.ts +31 -0
  113. package/src/shared/packed/schema.ts +23 -0
  114. package/src/shared/{packed.ts → packed/wire.ts} +31 -39
  115. package/src/shared/proven.ts +189 -496
  116. package/src/shared/runtime.ts +20 -25
  117. package/src/shared/uuid.ts +12 -28
  118. package/src/data/json-rows.ts +0 -33
  119. package/src/rust-ffi/batch.ts +0 -431
  120. package/src/shared/bench-classify.ts +0 -209
@@ -29,55 +29,14 @@ The benchmark system compares the Rust implementations against pure JavaScript/B
29
29
  bun bench.ts
30
30
  ```
31
31
 
32
- ### Performance-proven surface & auto-annotated JSDoc
33
-
34
- The CPU benchmark persists a machine-readable report to `bench/results/cpu/latest.json`
35
- (+ timestamped snapshots). Three commands consume it; `bun run check:docs` runs all
36
- three as the one-stop "informed decision" pipeline:
37
-
38
- | Command | What it does |
39
- |---------|--------------|
40
- | `bun run check` | Runs the CPU benchmark and writes `bench/results/cpu/latest.json` (report-only — **never mutates source**). |
41
- | `bun run check:annotate` | Rewrites the `@performance` / `@deprecated` / `@remarks` JSDoc on the public `rust.*` declarations (`src/rust-ffi/scalar/interface.ts`, `batch.ts`, `client.ts`) from the report. Idempotent — generated lines carry the `[check:annotate]` marker; hand-written JSDoc is preserved. `--dry-run` previews changes. |
42
- | `bun run check:proven` | Audits the `PROVEN_SURFACE` registry against the report and flags `REGRESSION`, `PARITY-DRIFT`, and `PROMOTABLE`. `--fail` exits 1 on regressions. |
43
- | `bun run check:docs` | `check` + `check:annotate` + `check:proven` — the full pipeline. |
44
-
45
- **Automatic annotation (opt-in):** `CASTRUM_BENCH_ANNOTATE=1 bun run check`
46
- auto-runs the annotator right after the benchmark, so the JSDoc always reflects
47
- the latest run. It is opt-in because it mutates tracked source files.
48
-
49
- **How the annotations work (data-driven):**
50
-
51
- - Each function's **score** is embedded in the `@performance` line, e.g.
52
- `JSON valid: ~3.2x faster than the JS baseline (8/8 tasks won)` or
53
- `CRC32: ~4.1x slower than the JS baseline (2/3 tasks failed)`. A function's
54
- benchmark set is the canonical comparison plus all its `_`-suffixed variants
55
- (`_large`, `_batch`, `_concurrent_*`, `_stress`, pooled `_into` overloads)
56
- that no other registry entry claims.
57
- - Task bands (see `src/shared/bench-classify.ts`): **won** = `ratio >= 1`
58
- (Rust at least as fast), **failed** = `ratio < 1 / 1.35` (clear loss), the
59
- middle is a **tie** that counts toward neither.
60
- - `@deprecated` is **hybrid**:
61
- - A clear **majority** of a function's benchmarks failing in the latest run
62
- auto-deprecates it even when the static registry says `proven`/`parity` — a
63
- `@remarks Auto-deprecated: N/M benchmarks failed ...` note records the drift.
64
- - A single good run **never** removes an existing `@deprecated` (static
65
- classifications reflect the shipped release build, not one local run). A
66
- majority win on a static `not-competitive` function only adds a
67
- `@remarks Promotion candidate ...` note.
68
- - Classifications are **build-dependent** (debug/SIMD builds distort ratios). The
69
- annotator warns when the report carries no `buildFlavor`; confirm on a release
70
- build (`bun run build`) or local perf build (`bun run build:perf`).
71
-
72
32
  ### Bun built-ins diagnostic set ("don't reinvent the wheel")
73
33
 
74
34
  `bun run check` also races castrum ops against **Bun's native built-ins**
75
35
  (`Bun.hash`, `Bun.password`, `Bun.CryptoHasher`, `Bun.gzipSync`,
76
36
  `Bun.randomUUIDv7`) as a diagnostic set:
77
37
 
78
- - Task names use the **`diag:`** prefix (not `native:`/`rust:`) so
79
- `check-proven`'s aggregation (which matches `PROVEN_SURFACE` rustTask names
80
- and their `_`-suffixed variants) never picks them up — they are informational.
38
+ - Task names use the **`diag:`** prefix (not `native:`/`rust:`) so they stay out
39
+ of the shipped-op `native:`/`rust:` comparisons — they are informational.
81
40
  - Sources: `src/baseline/tasks/bun-builtins.ts` (Bun-only baselines, guarded
82
41
  with `typeof Bun` so the module still loads under Node),
83
42
  `src/bench/tasks/bun-builtins.ts`, comparisons in `src/bench/comparisons.ts`.
@@ -112,8 +71,115 @@ bun run bench:http:bun-only # Run only raw Bun vs baseline
112
71
 
113
72
  # Run all heavy scenarios
114
73
  bun run bench:http:all-heavy
74
+
75
+ # Max-throughput cross-check (autocannon — no Bun fetch cap)
76
+ bun run bench:http:ac # all servers, 03-stress weighted mix
77
+ bun run bench:http:stress:ac # 03-stress mix only
78
+
79
+ # Single-path static comparison (fastest, most stable signal — SERVER-BOUND
80
+ # by default with AC_CONNECTIONS=2000; use this for ceiling comparisons):
81
+ # AC_PATH=/api/users AC_RUNS=3 node bench/autocannon-stress.mjs
82
+ # AC_PATH=/api/users AC_METHOD=POST AC_BODY='{"id":1,"name":"x"}' \
83
+ # AC_CONTENT_TYPE=application/json AC_RUNS=3 \
84
+ # node bench/autocannon-stress.mjs
85
+ # # median-of-N (see §Single-core): run-major interleaving — every server is
86
+ # # measured once per round, so the median compares servers over the same
87
+ # # load windows on a noisy host.
88
+ #
89
+ # The DYNAMIC 03-stress mix (default, no AC_PATH) is CLIENT-bound (~47k here —
90
+ # autocannon's setupRequest generation is the cap, identical across servers);
91
+ # use it only for shape/behavior validation, not ceiling comparisons.
92
+ #
93
+ # Multi-core scaling (SO_REUSEPORT — the way to exceed the single-core ceiling):
94
+ # AC_INSTANCES=4 node bench/autocannon-stress.mjs
95
+ # (ingress/router only — they read INGRESS_REUSE_PORT=1)
115
96
  ```
116
97
 
98
+ ### Bun `fetch()` concurrency cap (read this before interpreting HTTP results)
99
+
100
+ Bun's `fetch()` limits the number of **simultaneous** requests to **256 by
101
+ default** (`BUN_CONFIG_MAX_HTTP_REQUESTS`, max 65,336). When a scenario's
102
+ `maxConcurrent` exceeds that cap, the excess requests queue **FIFO client-side**
103
+ and the run measures the *generator*, not the server — the classic signature is
104
+ a sub-ms `p50` with a multi-second tail and a flat RPS wall across every server.
105
+
106
+ - All `bench:http:*` scripts set `BUN_CONFIG_MAX_HTTP_REQUESTS=65535` for you.
107
+ - `bench/http/run-bench.ts` warns if a selected scenario exceeds the effective cap.
108
+ - For **max-throughput** server comparisons use **autocannon**
109
+ (`bench/autocannon-stress.mjs`, run with `node`): it has no such cap, uses a
110
+ real socket pool, scales across worker threads, and reports RPS + latency
111
+ percentiles natively. It replicates the `03-stress` weighted flow mix
112
+ (50% GET /api/users?q=…&page=…, 30% POST /api/users, 20% GET /health).
113
+ - **Two autocannon gotchas** (both fixed in the runner):
114
+ 1. `requestGenerator` is CLI-ONLY — the library silently ignores it (every
115
+ request then goes to `GET /` → 404 → "0 2xx"). The runner uses the
116
+ library's `requests: [{ setupRequest }]` API instead.
117
+ 2. `result.workers` is a count (`opts.workers`), not an array — the report no
118
+ longer treats it as a per-worker array.
119
+
120
+ ### Single-core ceiling & multi-core scaling (measured 2026-08-21)
121
+
122
+ All four servers are **single-process `Bun.serve`** (one event loop), so they
123
+ pin at a per-core ceiling on this machine. The host is load-noisy, so use the
124
+ **median-of-N** runner (`AC_RUNS=N`, default 1) with **run-major interleaving** —
125
+ every server is visited once per round, so a single slow window affects all
126
+ servers' run-`i` equally and the median compares servers over the same load
127
+ windows (the old server-major order biased later servers into noisier windows).
128
+
129
+ > **Methodology (2026-08-21 — replaces the 2026-08-16 snapshot).** Two
130
+ > measurement traps were identified and the numbers below use the config that
131
+ > avoids both:
132
+ >
133
+ > 1. **The dynamic `setupRequest` mix is CLIENT-bound, not server-bound.** The
134
+ > 03-stress weighted mix caps every server at ~47k RPS on this host because
135
+ > autocannon's per-request `setupRequest` (random string generation + JSON
136
+ > stringify) is the bottleneck — all four servers report the identical
137
+ > ~47k. **Use static-path runs** (`AC_PATH=…`, `AC_METHOD=…`, `AC_BODY=…`)
138
+ > for ceiling comparisons; the mix only validates shape/behavior.
139
+ > 2. **`AC_PIPELINING>1` is a client/transport artifact under GET here.**
140
+ > With pipelining 10, even raw Bun collapses to ~8.6k on `GET /health`
141
+ > (autocannon pipelining + Bun's HTTP/1.1 handling), while the same server
142
+ > does 52k at pipelining 1 — so pipelining does NOT expose the ceiling on
143
+ > this stack. The server-bound config is **`AC_PIPELINING=1` + enough
144
+ > connections** (≥2000 on this host) to drive the event loop.
145
+ >
146
+ > The server-bound snapshot below is **static-path, `AC_CONNECTIONS=2000`,
147
+ > `AC_PIPELINING=1`, `AC_RUNS=3`** (median-of-3), single instance.
148
+
149
+ Current medians (static path, 2000 connections, pipelining 1, single instance):
150
+
151
+ - `GET /api/users?q=testquery&page=1` (query + cookies + CORS + metadata
152
+ envelope): **bun 52.5k, elysia 52.4k, ingress 95.9k, router 95.8k RPS** —
153
+ ingress/router ~**+83% vs bun**, with a tighter tail (router p99.9 195ms vs
154
+ bun 800ms).
155
+ - `POST /api/users` (JSON body, fast-path schema validation):
156
+ **bun 49.5k, elysia 49.5k, ingress 95.8k, router 93.5k RPS** — ~**+89–94%
157
+ vs bun**. The pre-optimization ingress POST gap (≈ −24% vs bun, 2026-08-16)
158
+ is long closed; the current gap is a large WIN (JS hot-path elimination +
159
+ single-pass body validation + copy-mode response default).
160
+ - **Response mode matters more than any remaining pipeline cost**: copy mode
161
+ (default) vs zero-copy on `POST /api/users`: **66.4k vs 45.1k RPS (+47%)** —
162
+ per-request `ReadableStream` construction is the cost, not the native body
163
+ write. Both servers default to copy mode; `INGRESS_ZERO_COPY=1` opts back in
164
+ for large-payload deployments.
165
+ - Per-request cost breakdown (`bun bench/cost/router-cost.ts` +
166
+ `router-minimal-breakdown.ts`, min-of-5): the router's minimal route `run()`
167
+ is ~310ns (native pruned pipeline ~115ns + response/run machinery ~150ns +
168
+ refresh 25ns + rid 12ns + header gather 4ns). A `syncResponder` experiment
169
+ that threaded a known-sync hint through `run()` was REVERTED as **slower**
170
+ (−6 to −94ns across 3 runs) — the per-request `assertSyncCallback` +
171
+ `instanceof Response` checks are already JIT-inlined and free on JSC; the
172
+ extra param/branch deoptimized the shared hot function. Conclusion: the
173
+ `run()` machinery is NOT real overhead.
174
+ - **To exceed the single-core ceiling**, run multiple server processes on the
175
+ same port via SO_REUSEPORT: `AC_INSTANCES=N` (ingress/router only — they read
176
+ `INGRESS_REUSE_PORT=1`; bun/elysia do not set it). On this noisy host the
177
+ scaling signal is unreliable (4 instances: 52.8k median with one run at
178
+ 91.9k; 8 instances: 48.5k — the load-noise band swallows the gain), so for
179
+ clean multi-core numbers run autocannon on a separate host. The mechanism
180
+ (N event loops sharing the accept queue) is the documented way to exceed the
181
+ single-process ceiling.
182
+
117
183
  ---
118
184
 
119
185
  ## HTTP Benchmark Scenarios
@@ -134,7 +200,9 @@ Each scenario targets a specific performance aspect:
134
200
  ### 03-stress
135
201
  - **Goal**: Ramp to maximum throughput
136
202
  - **Duration**: ~100 seconds (5 × 20s ramp phases)
137
- - **Concurrency**: up to 10,000
203
+ - **Concurrency**: up to 2,000 (higher on a single machine only deepens the
204
+ client-side fetch queue — the generator can't drive 10k usefully; see the
205
+ Bun fetch-cap note above. For true max-throughput use `bench:http:ac`.)
138
206
 
139
207
  ### 04-spike
140
208
  - **Goal**: Burst resilience (baseline → spike → recovery)
@@ -235,7 +303,7 @@ When comparing multiple frameworks, differences are expressed as:
235
303
 
236
304
  ```
237
305
  bench.ts
238
- └─ Loads benchmark tasks from src/bench/tasks/ (30 tasks)
306
+ └─ Loads benchmark tasks from src/bench/tasks/ (34 tasks)
239
307
  ├── hashing.ts Compare Rust fnv1a64 vs JS TextEncoder + hash
240
308
  ├── validation.ts Compare Rust validation vs validator library
241
309
  ├── json.ts Compare Rust sonic-rs vs JSON.parse
@@ -258,19 +326,19 @@ Each task runs:
258
326
  | Env / flag | Effect |
259
327
  |------------|--------|
260
328
  | `CASTRUM_BENCH_BATCH_SIZE` | Batch size for sub-µs operations (default `64`). Batching amortizes the timer/measurement overhead for very fast ops. |
261
- | `CASTRUM_BENCH_ANNOTATE=1` | After `bun run check`, auto-rewrite the `rust.*` JSDoc annotations from the fresh report (see "Performance-proven surface & auto-annotated JSDoc"). |
262
- | `HTTP_NO_SHAPE=1` | The HTTP load generator (`bench/load.ts`) skips response-shape `JSON.parse` for pure-throughput runs. |
329
+ | `HTTP_NO_SHAPE=1` | The HTTP load generator (`bench/http/load.ts`) skips response-shape `JSON.parse` for pure-throughput runs. |
263
330
 
264
331
  ---
265
332
 
266
333
  ## HTTP Server Architecture
267
334
 
268
335
  ```
269
- bench/servers/
336
+ bench/http/servers/
270
337
  ├── bun-server.ts # Raw Bun.serve with manual routing
271
338
  ├── elysia-server.ts # Elysia framework
272
339
  ├── ingress-server.ts # Ingress (this project) optimized handler
273
- └── shared.ts # Shared route handlers
340
+ ├── router-server.ts # Ingress per-route compiled-router variant (createIngressRouter)
341
+ └── shared.ts # Shared route handlers, PORTS, env helpers
274
342
  ```
275
343
 
276
344
  Each server implements the same routes for fair comparison:
@@ -0,0 +1,243 @@
1
+ # Case study — building a Rust-accelerated HTTP pipeline for Bun
2
+
3
+ **castrum** is a hybrid TypeScript + Rust package that gives Bun servers native
4
+ speed for the work that actually costs CPU — request validation, parsing,
5
+ hashing, crypto, and a full HTTP ingress pipeline — behind a small, flat API.
6
+ This document tells the story of how it was built and measured, what the
7
+ benchmarks actually showed, and the lessons that generalise to any
8
+ "should I write this in Rust?" decision.
9
+
10
+ All figures below come from the reports in `bench/results/` (generated on a
11
+ release addon, baseline CPU, Bun 1.4.0). They're **representative**, not
12
+ advertised as universal — re-run `bun run check` / `bun run bench:http` on your
13
+ own hardware before trusting them for your numbers.
14
+
15
+ ---
16
+
17
+ ## 1. Background: the problem
18
+
19
+ Bun is already very fast. The gap that motivated castrum was not "Bun is slow"
20
+ but three specific frictions:
21
+
22
+ 1. **CPU-bound request work runs in JavaScript.** JSON validity checking, email
23
+ / UUID / IP validation, query & cookie parsing, hashing, and schema
24
+ validation are pure CPU. In JS they compete with the event loop; the moment
25
+ a request needs several of them, tail latency climbs.
26
+ 2. **Production HTTP servers need a lot of "plumbing" middleware.** CORS,
27
+ rate limiting, body-size guards, schema validation, security headers,
28
+ request IDs, metrics. Hand-wiring these into `Bun.serve` is repetitive and
29
+ error-prone; pulling in a framework drags in a dependency graph.
30
+ 3. **Bun and Node want different integration stories.** The same logic should
31
+ run on Bun (the primary target, zero startup cost) and Node ≥ 20.3 (a
32
+ compiled ESM entry) without two implementations.
33
+
34
+ The hypothesis: a **hybrid** design — ergonomic TypeScript on top of one Rust
35
+ cdylib — could remove the CPU cost from the event loop, package the middleware
36
+ as a pre-baked pipeline, and still be usable from both runtimes.
37
+
38
+ ---
39
+
40
+ ## 2. The approach
41
+
42
+ ### 2.1 One cdylib, two transports
43
+
44
+ The addon is built with napi-rs (so Node can load it), but it *also* exports
45
+ `extern "C"` symbols (`rust/ffi/`, **85 `castrum_*` symbols** — 77 direct + 4
46
+ `validator_c_abi!` + 4 `compress_to_out!`) so Bun can
47
+ `dlopen` it directly:
48
+
49
+ - **`bun:ffi` (primary on Bun)** — Bun JIT-calls the C-ABI exports at
50
+ ~10–20 ns per crossing, roughly an order of magnitude cheaper than a NAPI
51
+ call. A **bind-time self-test** verifies every symbol and the ingress layout
52
+ blob; on *any* failure the layer disables itself and calls fall back to NAPI.
53
+ - **NAPI (fallback)** — used under Node, when `CASTRUM_FFI_MODE=napi`, or when
54
+ the self-test fails. It's the safety net that keeps the same code working on
55
+ both runtimes.
56
+
57
+ ```mermaid
58
+ flowchart LR
59
+ TS["TypeScript (index.ts)"] -->|"bun:ffi · 85 C-ABI symbols · ~10–20 ns"| RUST["Rust cdylib (rust/ffi/)"]
60
+ TS -.->|"NAPI fallback (Node / self-test failure)"| RUST
61
+ RUST --> PIPELINE["8-stage ingress pipeline"]
62
+ TS --> PACKED["Packed, length-prefixed buffers (zero-copy)"]
63
+ PACKED --> RUST
64
+ ```
65
+
66
+ ### 2.2 Zero-copy, zero-alloc request handling
67
+
68
+ Instead of marshalling objects, the hot path **packs** the request frame
69
+ (method kind, URL, client IP, request ID, headers) into a shared `Uint8Array`,
70
+ Rust parses it and writes a decision into a **single output buffer**, and TS
71
+ decodes only the fields it needs. Hot paths allocate nothing per request;
72
+ pooled output buffers (with an adaptive-estimate size heuristic) are reused.
73
+
74
+ ### 2.3 The ingress pipeline as a product, not a library
75
+
76
+ The 8-stage pipeline (trust/IP → HTTPS → CORS → rate limit → body guard →
77
+ JSON/schema → serialize cookies/query → write output) ships twice:
78
+ `createIngressServer` wraps it in a `Bun.serve` route table, and the same
79
+ handlers run over `node:http` via `createIngressServerNode`.
80
+
81
+ > **Two wire formats on purpose.** The low-level fast path emits
82
+ > `{"error":{...}}` with `x-ratelimit-*`; the pre-baked server emits
83
+ > `{"ok":true,...,"requestId":...}` with `ratelimit-*`. They are a **contract**
84
+ > the load generator validates — unifying them would break the benchmark
85
+ > baseline. (Recorded in `docs/adr/0001-two-wire-formats.md`.)
86
+
87
+ ---
88
+
89
+ ## 3. Results
90
+
91
+ ### 3.1 Startup & first call (the "is it instant?" test)
92
+
93
+ From `bench/results/startup/latest.json` (Bun 1.4.0, 15 iterations):
94
+
95
+ | Metric | p50 | p95 |
96
+ |--------|-----|-----|
97
+ | Import the package (raw `index.ts`, no build step) | ~90 ms | ~112 ms |
98
+ | First `rust.crc32` call (ffi transport already warm) | ~0.04 ms | ~0.08 ms |
99
+ | First ingress pipeline call (addon loaded) | ~2.6 ms | ~5.3 ms |
100
+
101
+ The important number is the first-*call* cost: the cdylib is lazy-loaded, so
102
+ the import itself doesn't pay a native-init penalty.
103
+
104
+ ### 3.2 CPU: where Rust wins, and where it doesn't
105
+
106
+ The CPU benchmark races every `rust.*` op against a pure-JS baseline **and**
107
+ Bun's built-ins (`docs/bun-builtins-decision-matrix.md`, release addon,
108
+ 2026-08-11). Ratio > 1 = Bun is faster.
109
+
110
+ | Op | Bun built-in | Ratio | Verdict |
111
+ |----|--------------|-------|---------|
112
+ | `passwordHash` (argon2id) | `Bun.password.hashSync` | **0.55** (rust 1.83×) | **keep rust** |
113
+ | `passwordVerify` (argon2id) | `Bun.password.verifySync` | **0.53** (rust 1.88×) | **keep rust** |
114
+ | `passwordVerifyBcrypt` | `Bun.password.verifySync` | **0.67** (rust 1.49×) | **keep rust** |
115
+ | `pbkdf2Sha256` | `node:crypto.pbkdf2Sync` | 0.93 (parity) | keep rust |
116
+ | `fnv1a64` | `Bun.hash` (wyhash) | 1.06 | parity |
117
+ | `hmacSha256` | `Bun.CryptoHasher` | 1.1–1.4 | mild Bun win |
118
+ | `randomToken` | `Bun.randomUUIDv7` | 1.62 | **Bun wins** |
119
+ | `gzipDecompress` | `Bun.gunzipSync` | 1.38 | Bun wins* |
120
+ | `gzipCompress` | `Bun.gzipSync` | 2.02 | **Bun wins** |
121
+ | `crc32` | `Bun.hash.crc32` | 2.8–8.4 | **Bun wins** |
122
+ | `xxh3` | `Bun.hash.xxHash3` | 4.15 | **Bun wins** |
123
+ | brotli / zero-DOM JSON / parsers | *no sync Bun equivalent* | — | keep rust |
124
+
125
+ \* `gzipDecompress` deliberately stays native: the Rust path enforces a **64 MiB
126
+ decompression-bomb cap** that `Bun.gunzipSync` lacks.
127
+
128
+ **The honest takeaway:** "native is faster" is *not* automatic. The runtime
129
+ adapter's `builtins` registry (`src/runtime/builtins.ts`) delegates the ops
130
+ where a Bun built-in wins (e.g. `rust.xxh3` loses 4× to `Bun.hash.xxHash3`),
131
+ while argon2id and the zero-DOM JSON path stay native. A consumer-facing selection
132
+ surface (`opImpl` / `isNativeOp` / `opDecision`) lets frameworks bind each op to
133
+ native or JS **once at load time** instead of per call.
134
+
135
+ ### 3.3 HTTP: the pipeline holds up under load
136
+
137
+ From `bench/results/*/03-stress.bench.md` (ramp to 10k concurrent, ~104 s;
138
+ numbers below are the latest run, 2026-08-21 — re-run
139
+ `bun run bench:http:stress` to refresh):
140
+
141
+ | Server | Achieved RPS | Total requests | Unexpected errors | Shape failures |
142
+ |--------|-------------|----------------|-------------------|----------------|
143
+ | raw `Bun.serve` | 929.26 | 96,362 | 0 | 0 |
144
+ | **castrum ingress** | **928.65** | **96,369** | **0** | **0** |
145
+
146
+ Under extreme concurrency both saturate and show long tail latencies (p99 ~18 s
147
+ on this machine) — that's the load generator over-driving the box, not a bug.
148
+ The meaningful result is that adding the full pipeline (CORS + rate limit +
149
+ body guard + schema + security headers + request IDs) costs **no correctness
150
+ and no measurable throughput** versus a hand-rolled raw server (the two are at
151
+ parity, within noise).
152
+
153
+ ### 3.4 The bug that almost killed the server: C-ABI panics
154
+
155
+ The most instructive moment: under `11-concurrent-burst`, the ingress server
156
+ **died at ~2.5 s and stayed down** (91% network errors). Root cause: the `bun:ffi`
157
+ `extern "C"` exports had no panic guard. A panic in a napi call becomes a JS
158
+ exception, but a panic unwinding through raw `extern "C"` is undefined behaviour
159
+ that takes the whole process down.
160
+
161
+ The fix (in `rust/ffi/`): every fallible / allocating C-ABI export now routes
162
+ its core through `panic_guard` (`catch_unwind`) and reports a sentinel instead
163
+ of panicking. After the fix, the same scenario completes cleanly:
164
+
165
+ | Scenario (post-fix) | Requests | Network errors | Unexpected errors |
166
+ |---------------------|----------|----------------|-------------------|
167
+ | `11-concurrent-burst` | 30,211 | 0 | 0 |
168
+
169
+ **Lesson:** if you expose a raw C ABI to a host runtime, you own the unwind
170
+ boundary. Never assume a panic can't escape — contain it explicitly, and make
171
+ your bind-time self-test exercise the fallible paths.
172
+
173
+ ### 3.5 Correctness as a first-class gate
174
+
175
+ The HTTP smoke scenario (`01-smoke`) is a **wire-format guard**, not a
176
+ throughput test: it verifies `ok === true` + `requestId` on success and
177
+ `error.code` / `error.message` on failure for every response. Across the
178
+ scenarios, `shape_failures` and `unexpected_statuses` are 0 — the two wire
179
+ formats are validated every CI run, so a change to the response shape breaks
180
+ the benchmark, not prod.
181
+
182
+ ---
183
+
184
+ ## 4. Engineering decisions & lessons
185
+
186
+ 1. **Two transports, one binary.** Build with napi-rs (portability), export
187
+ `extern "C"` (speed on Bun), and let a **bind-time self-test** pick the safe
188
+ path automatically. Never let a transport failure be silent.
189
+ 2. **Benchmark against the built-in, not just the baseline.** Bun's `Bun.hash.*`,
190
+ `Bun.gzipSync`, `Bun.CryptoHasher`, and `Bun.randomUUIDv7` beat an FFI
191
+ crossing on several ops. The rule that emerged: **keep Rust where it wins or
192
+ where Bun has no synchronous equivalent; delegate where Bun wins.** Don't
193
+ reinvent the wheel — `docs/adr/0003-bun-builtins-delegation.md`.
194
+ 3. **"Native" is not a synonym for "fast".** The `builtins` registry delegates
195
+ where Bun wins. Let data, not enthusiasm, decide.
196
+ 4. **A wire format is a contract.** Two deliberately-different formats are
197
+ pinned by tests and the load generator. If you change the shape, you change
198
+ the benchmark.
199
+ 5. **Contain the C ABI.** A panic across `extern "C"` is a crash. Route every
200
+ fallible export through a guard and cover it in the self-test.
201
+ 6. **Zero-copy is a discipline.** Packed buffers, pooled outputs, lazy decode —
202
+ every allocation you remove from the hot path is tail latency you never pay.
203
+ 7. **Be honest about scope.** The rate limiter is **per-process, not
204
+ distributed**; proxy trust is **off by default** (spoof-proof); the
205
+ decompression bomb cap is **64 MiB**. Documenting the boundaries is as
206
+ important as the speed.
207
+
208
+ ---
209
+
210
+ ## 5. Reproduce it yourself
211
+
212
+ ```bash
213
+ bun install
214
+ bun run build # release addon (baseline CPU — what ships)
215
+
216
+ bun run check # CPU benchmark → bench/results/cpu/latest.json
217
+ bun run bench:startup # import + first-call timing
218
+ bun run bench:http:smoke # HTTP wire-format guard
219
+ bun run bench:http # all HTTP scenarios (bun / elysia / ingress)
220
+ ```
221
+
222
+ See [`BENCHMARKS.md`](./BENCHMARKS.md) for scenario descriptions and how to read
223
+ the reports.
224
+
225
+ ---
226
+
227
+ ## 6. When this pattern applies (and when it doesn't)
228
+
229
+ The hybrid TS + Rust + FFI pattern paid off here because the workload was
230
+ **CPU-bound, synchronous, and high-volume**. Reach for it when:
231
+
232
+ - Request handling spends measurable time in validation/parsing/hashing.
233
+ - You want a pre-baked HTTP pipeline (CORS, rate limit, schema, security
234
+ headers) without a heavy middleware graph.
235
+ - You must support both Bun and Node from one codebase.
236
+
237
+ Don't reach for it when:
238
+
239
+ - The bottleneck is I/O or the database, not CPU — Rust won't help.
240
+ - You only need a handful of ops that Bun's built-ins already win (use
241
+ `Bun.hash.crc32`, `Bun.gzipSync`, `Bun.randomUUIDv7` directly).
242
+ - You can't ship/ build a native `.node` in your deployment (no Rust toolchain
243
+ in CI, or a locked-down runtime that forbids native addons).
@@ -4,13 +4,15 @@ This document lists every environment variable read by the package and its
4
4
  example servers. All values are read once at module load; invalid values are
5
5
  warned about and replaced with the documented default.
6
6
 
7
- ## Ingress server (`bench/servers/ingress-server.ts`)
7
+ ## Ingress server (`bench/http/servers/ingress-server.ts`)
8
8
 
9
9
  | Variable | Type | Default | Description |
10
10
  |----------|------|---------|-------------|
11
11
  | `INGRESS_TRUST_PROXY` | bool | `false` | **Opt-in.** When `true`, honors `X-Forwarded-For` / `X-Real-IP` for client-IP resolution. Keep **off** unless the server sits behind a trusted edge that strips these headers, otherwise clients can spoof arbitrary IPs for IP-based rate limiting. |
12
12
  | `INGRESS_REQUEST_ID_HEADER` | bool | `false` | Emit an `x-request-id` response header per request. The request ID is always generated internally; this only controls whether it is echoed to clients. |
13
- | `INGRESS_UNSAFE_ZERO_COPY` | bool | `false` | Enable zero-copy response bodies. **Not recommended** unless per-response output buffers are implemented; leave off for safety (bodies are copied by default). |
13
+ | `INGRESS_ZERO_COPY` | bool | `false` | Enable zero-copy response bodies (legacy alias `INGRESS_UNSAFE_ZERO_COPY` still honored). **Not recommended** unless per-response output buffers are implemented; leave off for safety (bodies are copied by default). |
14
+ | `INGRESS_ZERO_COPY_MAX_IN_FLIGHT` | int | `128` | Max pooled output buffers kept in flight for zero-copy responses (min `1`). |
15
+ | `INGRESS_ZERO_COPY_TIMEOUT_MS` | int | `1000` | Timeout before a zero-copy pooled buffer is force-released (min `0`). |
14
16
  | `INGRESS_OUTPUT_BUF_BYTES` | int | `131072` | Size of the Rust output buffer (min `65536`). If cookies + query + metadata JSON exceed this, `FLAG_BODY_TRUNCATED` is set and the request fails closed. |
15
17
  | `INGRESS_SECURITY_HEADERS` | bool | `true` | Emit the precomputed security headers (CSP, HSTS, X-Frame-Options, etc.). Disable only behind an edge that already sets them. |
16
18
  | `INGRESS_REUSE_PORT` | bool | `false` | Enable `SO_REUSEPORT` for multi-process deployments (spawn N processes, each with a distinct `INGRESS_WORKER_ID`). |
@@ -27,9 +29,9 @@ for false; anything else is warned about and falls back to the default.
27
29
 
28
30
  | Variable | Type | Default | Description |
29
31
  |----------|------|---------|-------------|
30
- | `CASTRUM_RAYON_THREADS` / `RUST_RAYON_THREADS` (legacy `RUST_BENCH_RAYON_THREADS`) | int | `max(1, cores-1)` | Rayon thread pool size. The pool is process-wide and initialized **once** — the first use wins. With lazy init, call `rust.configure({ rayonThreads })` (or set this env) before the first batch operation. |
31
- | `CASTRUM_MAX_RAYON_THREADS` (legacy `RUST_BENCH_MAX_RAYON_THREADS`) | int | `cores` | Hard cap on rayon threads (native). |
32
- | `CASTRUM_PIN_CORES` (legacy `RUST_BENCH_PIN_CORES`) | (presence) | — | When set (Linux), pin rayon worker threads to distinct cores. |
32
+ | `CASTRUM_RAYON_THREADS` | int | `max(1, cores-1)` | Rayon thread pool size. The pool is process-wide and initialized **once** — the first use wins. With lazy init, call `rust.configure({ rayonThreads })` (or set this env) before the first batch operation. |
33
+ | `CASTRUM_MAX_RAYON_THREADS` | int | `cores` | Hard cap on rayon threads (native). |
34
+ | `CASTRUM_PIN_CORES` | (presence) | — | When set (Linux), pin rayon worker threads to distinct cores. |
33
35
 
34
36
  ## Native transport selection (`src/native/ffi.ts`)
35
37
 
@@ -40,13 +42,13 @@ whether the ffi transport is live.
40
42
 
41
43
  | Variable | Type | Default | Description |
42
44
  |----------|------|---------|-------------|
43
- | `CASTRUM_FFI_MODE` (legacy `RUST_FFI_MODE`) | string | `auto` | Native transport selection: `auto` (default) — use `bun:ffi` on Bun, silently fall back to napi on bind/self-test failure (and always on Node); `ffi` — force `bun:ffi` on Bun and THROW if it cannot bind or the self-test fails (for benches/CI that must run the primary transport; invalid on Node); `napi` — never bind, every call goes through the napi addon (exercises the fallback on Bun). |
45
+ | `CASTRUM_FFI_MODE` | string | `auto` | Native transport selection: `auto` (default) — use `bun:ffi` on Bun, silently fall back to napi on bind/self-test failure (and always on Node); `ffi` — force `bun:ffi` on Bun and THROW if it cannot bind or the self-test fails (for benches/CI that must run the primary transport; invalid on Node); `napi` — never bind, every call goes through the napi addon (exercises the fallback on Bun). |
44
46
 
45
47
  ## Native addon loader (`src/native/loader.ts`)
46
48
 
47
49
  | Variable | Type | Default | Description |
48
50
  |----------|------|---------|-------------|
49
- | `CASTRUM_DEBUG` (legacy `RUST_BENCH_DEBUG`) | (presence) | — | Log the resolved addon path and exported keys at load time. |
51
+ | `CASTRUM_DEBUG` | (presence) | — | Log the resolved addon path and exported keys at load time. |
50
52
  | `CASTRUM_NATIVE_LIBRARY_PATH` / `NAPI_RS_NATIVE_LIBRARY_PATH` | path | — | Explicit path (file or directory) to the native `.node` artifact, overriding normal resolution. Useful for bundled/electron layouts. |
51
53
 
52
54
  ## Library logging (`src/shared/log.ts`)
@@ -55,6 +57,21 @@ whether the ffi transport is live.
55
57
  |----------|------|---------|-------------|
56
58
  | `CASTRUM_LOG_LEVEL` | string | `info` | Threshold for the built-in structured logger (`debug`/`info`/`warn`/`error`/`silent`). Enabled per handler via the `structuredLog: true` runtime option; emits one JSON line per request/error to stderr (`{ts, level, event, requestId, method, path, status, durationMs, ...}`). |
57
59
 
60
+ ## Benchmark controls (`bench/`, `src/bench/`)
61
+
62
+ | Variable | Type | Default | Description |
63
+ |----------|------|---------|-------------|
64
+ | `CASTRUM_BENCH_BATCH_SIZE` | int | `64` | Fast-op batch size for the CPU bench's measurement loop (`src/bench/measure.ts`); for methodology experiments. |
65
+ | `CASTRUM_BENCH_FFI` | bool | — | When `1`, the CPU bench treats an unavailable FFI transport as a hard failure instead of a warning (`src/bench/run.ts`). |
66
+ | `CASTRUM_FFI_LOAD_MS` | int | `400` | Per-window duration (ms) of the `ffi-all` load-run (`bench/ffi/ffi-all.ts`). |
67
+ | `CASTRUM_FFI_LOAD_WINDOWS` | int | `4` | Number of load windows in `ffi-all` (`bench/ffi/ffi-all.ts`). |
68
+
69
+ ## Publishing (`scripts/prepublish.mjs`, `publish-manual.mjs`)
70
+
71
+ | Variable | Type | Default | Description |
72
+ |----------|------|---------|-------------|
73
+ | `CASTRUM_PUBLISH_ALLOW_PARTIAL` | bool | — | When `1`, `prepublishOnly` ships a tarball containing only the locally-built platforms instead of failing on the full `napi.targets` set. Set automatically by `bun run publish:manual`; **never** set it for a normal `npm publish`.
74
+
58
75
  ## Security guidance
59
76
 
60
77
  - **Proxy trust**: `trustProxy: true` is deprecated — it trusts EVERY hop of