castrum 0.9.6 → 0.9.8

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 (88) hide show
  1. package/Cargo.lock +4 -3
  2. package/Cargo.toml +1 -1
  3. package/castrum.darwin-arm64.node +0 -0
  4. package/castrum.darwin-x64.node +0 -0
  5. package/castrum.linux-arm64-gnu.node +0 -0
  6. package/castrum.linux-arm64-musl.node +0 -0
  7. package/castrum.linux-x64-gnu.node +0 -0
  8. package/castrum.linux-x64-musl.node +0 -0
  9. package/castrum.linux-x64-v3-gnu.node +0 -0
  10. package/castrum.win32-x64-msvc.node +0 -0
  11. package/dist/index.d.ts +501 -5
  12. package/dist/index.js +1658 -1314
  13. package/docs/ARCHITECTURE.md +1 -1
  14. package/docs/BENCHMARKS.md +13 -0
  15. package/docs/CASE_STUDY.md +2 -2
  16. package/docs/ENVIRONMENT.md +1 -1
  17. package/docs/FFI_BUN_GUIDE.md +13 -1
  18. package/docs/HOW_TO_ADD_AN_OP.md +24 -0
  19. package/docs/INGRESS.md +4 -4
  20. package/docs/REPO_MAP.md +8 -6
  21. package/docs/superpowers/plans/2026-09-18-castrum-hardening-cleanup.md +617 -0
  22. package/index.ts +1 -0
  23. package/package.json +3 -2
  24. package/rust/crypto/aead.rs +44 -0
  25. package/rust/crypto/argon2.rs +32 -2
  26. package/rust/crypto/jwt/tests.rs +19 -0
  27. package/rust/crypto/jwt/token.rs +43 -28
  28. package/rust/crypto/session.rs +170 -7
  29. package/rust/ffi/crypto.rs +182 -61
  30. package/rust/ffi/hashing.rs +48 -33
  31. package/rust/ffi/http.rs +62 -36
  32. package/rust/ffi/metrics.rs +6 -2
  33. package/rust/ffi/payload.rs +47 -27
  34. package/rust/ffi/task.rs +51 -32
  35. package/rust/ffi/tests.rs +197 -0
  36. package/rust/ffi/util.rs +40 -0
  37. package/rust/http/headers.rs +13 -0
  38. package/rust/http/multipart.rs +139 -0
  39. package/rust/ingress/native_route.rs +21 -3
  40. package/rust/task/completion.rs +7 -0
  41. package/rust/task/mod.rs +1 -1
  42. package/rust/task/tests.rs +30 -0
  43. package/src/ingress/decode/baked-result.ts +13 -5
  44. package/src/ingress/decode/fast-result.ts +1 -1
  45. package/src/ingress/decode/result-base.ts +16 -2
  46. package/src/ingress/handlers.ts +103 -31
  47. package/src/ingress/headers/memoized-headers.ts +40 -0
  48. package/src/ingress/native-route.ts +25 -6
  49. package/src/ingress/options.ts +26 -2
  50. package/src/ingress/packing/gather-raw-headers.ts +24 -8
  51. package/src/ingress/response/baked-response.ts +118 -28
  52. package/src/ingress/router.ts +14 -2
  53. package/src/ingress/routes/common.ts +26 -10
  54. package/src/ingress/routes/echo.ts +50 -7
  55. package/src/ingress/routes/json-write.ts +21 -8
  56. package/src/ingress/routes/responder.ts +4 -4
  57. package/src/ingress/server-node.ts +33 -14
  58. package/src/ingress/server.ts +3 -4
  59. package/src/ingress/shared.ts +1 -1
  60. package/src/ingress/types.ts +15 -0
  61. package/src/loader/ops.ts +1 -1
  62. package/src/native/ffi/build/codecs-selftest.ts +305 -0
  63. package/src/native/ffi/build/codecs.ts +0 -296
  64. package/src/native/ffi/build/instances-selftest.ts +264 -0
  65. package/src/native/ffi/build/instances.ts +39 -241
  66. package/src/native/ffi/build/metrics.ts +0 -7
  67. package/src/native/ffi/build/parse-selftest.ts +238 -0
  68. package/src/native/ffi/build/parse.ts +11 -234
  69. package/src/native/ffi/build.ts +5 -1
  70. package/src/native/ffi/selftest.ts +3 -3
  71. package/src/native/ffi/symbols.ts +460 -0
  72. package/src/native/ffi/types.ts +17 -0
  73. package/src/native/ffi.ts +2 -422
  74. package/src/native/ingress-binding.ts +39 -0
  75. package/src/runtime/codec.ts +24 -0
  76. package/src/runtime/types.ts +11 -0
  77. package/src/rust-ffi/batch/build.ts +18 -8
  78. package/src/rust-ffi/batch/types.ts +15 -0
  79. package/src/rust-ffi/options.ts +19 -0
  80. package/src/rust-ffi/scalar/crypto.ts +8 -0
  81. package/src/rust-ffi/scalar/factories.ts +47 -20
  82. package/src/rust-ffi/scalar/http.ts +60 -10
  83. package/src/rust-ffi/scalar/interface.ts +4 -1
  84. package/src/rust-ffi/scalar/payload.ts +27 -1
  85. package/src/shared/buffer-pool.ts +44 -9
  86. package/src/shared/codec.ts +26 -0
  87. package/src/shared/packed/parsers.ts +64 -7
  88. package/src/task/runtime.ts +29 -28
@@ -31,7 +31,7 @@ The bridge between TypeScript and Rust has **two transports**, both loading the
31
31
  cdylib (built with napi-rs):
32
32
 
33
33
  - **`bun:ffi` (PRIMARY on Bun)** — `src/native/ffi.ts` `dlopen`s the addon's
34
- `extern "C"` exports (`rust/ffi/`, 119 `castrum_*` symbols — 107 direct + 4
34
+ `extern "C"` exports (`rust/ffi/`, 121 `castrum_*` symbols — 109 direct + 4
35
35
  `validator_c_abi!` + 4 `validator_bytes_c_abi!` + 4 `compress_to_out!`,
36
36
  parity guarded by
37
37
  `test/unit/native/ffi-symbol-parity.test.ts`) and Bun JIT-calls them
@@ -162,6 +162,19 @@ Current medians (static path, 2000 connections, pipelining 1, single instance):
162
162
  per-request `ReadableStream` construction is the cost, not the native body
163
163
  write. Both servers default to copy mode; `INGRESS_ZERO_COPY=1` opts back in
164
164
  for large-payload deployments.
165
+ - **Response construction dominates the JS-side cost — and is now memoized.**
166
+ A Bun CPU profile of the ingress route under load attributed ~80% of sampled
167
+ JS time to `new Response(...)` (the whole `run()` ~7%, the FFI pipeline call
168
+ ~4%): passing an array-of-pairs makes Bun re-parse every header on every
169
+ response, measured **1832 ns vs 398 ns** for a persistent `Headers` instance
170
+ with the full security + CORS set. `buildSuccessInit` and the terminal
171
+ builders now pass a memoized `Headers`
172
+ (`src/ingress/headers/memoized-headers.ts`, keyed by the header array's
173
+ identity; the baked templates and per-origin cache return the same array
174
+ across requests, fresh per-request arrays are never retained). Interleaved
175
+ autocannon A/B (static `GET /api/users`, 8 workers, 2000 connections, three
176
+ independent runs): **+14.3% / +15.6% / +23.1% median RPS**, p50 −15%,
177
+ p99 −9…−24%, 0 errors, wire output byte-identical.
165
178
  - Per-request cost breakdown (`bun bench/cost/router-cost.ts` +
166
179
  `router-minimal-breakdown.ts`, min-of-5): the router's minimal route `run()`
167
180
  is ~310ns (native pruned pipeline ~115ns + response/run machinery ~150ns +
@@ -44,7 +44,7 @@ as a pre-baked pipeline, and still be usable from both runtimes.
44
44
  ### 2.1 One cdylib, two transports
45
45
 
46
46
  The addon is built with napi-rs (so Node can load it), but it *also* exports
47
- `extern "C"` symbols (`rust/ffi/`, **119 `castrum_*` symbols** — 107 direct + 4
47
+ `extern "C"` symbols (`rust/ffi/`, **121 `castrum_*` symbols** — 109 direct + 4
48
48
  `validator_c_abi!` + 4 `validator_bytes_c_abi!` + 4 `compress_to_out!`) so Bun can
49
49
  `dlopen` it directly:
50
50
 
@@ -58,7 +58,7 @@ The addon is built with napi-rs (so Node can load it), but it *also* exports
58
58
 
59
59
  ```mermaid
60
60
  flowchart LR
61
- TS["TypeScript (index.ts)"] -->|"bun:ffi · 85 C-ABI symbols · ~10–20 ns"| RUST["Rust cdylib (rust/ffi/)"]
61
+ TS["TypeScript (index.ts)"] -->|"bun:ffi · 121 C-ABI symbols · ~10–20 ns"| RUST["Rust cdylib (rust/ffi/)"]
62
62
  TS -.->|"NAPI fallback (Node / self-test failure)"| RUST
63
63
  RUST --> PIPELINE["8-stage ingress pipeline"]
64
64
  TS --> PACKED["Packed, length-prefixed buffers (zero-copy)"]
@@ -10,7 +10,7 @@ warned about and replaced with the documented default.
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_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). |
13
+ | `INGRESS_ZERO_COPY` | bool | `false` | Enable zero-copy response bodies (legacy alias `INGRESS_UNSAFE_ZERO_COPY` still honored). Off by default; when on, the handler serves a pooled output buffer via `pooledBodyResponse` (per-response output buffers are implemented — `src/shared/buffer-pool.ts`, `baked-response.ts::zeroCopyResponse`). Bound the borrowed set with `maxInFlight`. |
14
14
  | `INGRESS_ZERO_COPY_MAX_IN_FLIGHT` | int | `128` | Max pooled output buffers kept in flight for zero-copy responses (min `1`). |
15
15
  | `INGRESS_ZERO_COPY_TIMEOUT_MS` | int | `1000` | Timeout before a zero-copy pooled buffer is force-released (min `0`). |
16
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. |
@@ -240,6 +240,18 @@ measuring the bare binding suggested the opposite. **Measure through the functio
240
240
  users call, not the binding** — the guard itself costs ~4 ns, so guarding is
241
241
  cheap and routing everything through bytes is not always a win.
242
242
 
243
+ **A second measured instance: the session seal/open siblings (0.9.7).** They
244
+ land in the same place, and the hinge is the caller's own encode. Through the
245
+ real call path, 200k calls, ns/call: one short-string `TextEncoder.encode`
246
+ costs 45.4; `session_seal` is 613.5 via `cstring` versus 557.8 via
247
+ `castrum_session_seal_bytes` when every argument is ALREADY bytes (−9.1%), but
248
+ 620.6 when the caller re-encodes its JS strings per call (**+1.2%, slower**).
249
+ `session_open` mirrors it: 568.5 / 501.1 (−11.9%) / 579.5 (+1.9%). So ~45 ns of
250
+ encode against ~45–67 ns of transcode: the byte form pays only for a caller that
251
+ already holds bytes. Byte-identical tokens between the two forms was asserted at
252
+ runtime (`parity: true`), so the siblings are a correctness escape hatch — the
253
+ NUL case, or a caller that already owns buffers — never a blanket migration.
254
+
243
255
  **Rule**: a `cstring` ARG is only correct when truncation cannot change the
244
256
  answer — i.e. the string is developer-supplied config or NUL-free by
245
257
  construction (a MIME extension, a base64 key, a header name). Anything
@@ -480,7 +492,7 @@ a type error, not a behavior change.
480
492
  **Verify after any FFI change**:
481
493
 
482
494
  ```bash
483
- nm -D --defined-only <addon> | grep -c castrum_ # = 109
495
+ nm -D --defined-only <addon> | grep -c castrum_ # = 121 (109 direct extern fns)
484
496
  bun run bench:http:smoke # after touching decoders/handlers
485
497
  bun test test/unit/native/ffi.test.ts # FFI↔napi parity + self-test
486
498
  bun test test/unit/native/ffi-symbol-parity.test.ts
@@ -13,6 +13,30 @@ guards fail.
13
13
 
14
14
  ---
15
15
 
16
+ ### ⚠️ Rebuild BOTH binaries before binding anything
17
+
18
+ `bun run build` writes the **baseline** addon only. The loader CPU-detects and
19
+ PREFERS the x86-64-v3 SIMD variant (`castrum.linux-x64-v3-gnu.node`, built by
20
+ `bun run build:v3`) on any AVX2 host — i.e. essentially every modern x86-64
21
+ machine, including CI runners.
22
+
23
+ A stale v3 binary therefore ships the OLD symbol table, and the failure mode is
24
+ **not** a clean "missing symbol" error: `bun:ffi` binds against it, the first call
25
+ jumps through a missing symbol, and the process **SIGSEGVs** — often at teardown,
26
+ with every test passing. Observed while adding
27
+ `castrum_session_{seal,open}_bytes`: `test/compat` printed `9 pass / 0 fail` and
28
+ then died, `gdb` showing the fault inside `castrum.linux-x64-v3-gnu.node`.
29
+
30
+ So: after adding or changing ANY `castrum_*` export, rebuild with
31
+ `bun run build:all` (baseline + v3). If you hit a segfault with no failing test,
32
+ check this FIRST — it is much cheaper than bisecting the ABI:
33
+
34
+ ```bash
35
+ nm -D --defined-only castrum.linux-x64-v3-gnu.node | grep <your_symbol>
36
+ ```
37
+
38
+ ---
39
+
16
40
  ## 0. The six wiring points at a glance
17
41
 
18
42
  | # | File | What you add |
package/docs/INGRESS.md CHANGED
@@ -415,14 +415,14 @@ interface SyncIngressHandler {
415
415
 
416
416
  | Option | Type | Default | Description |
417
417
  |--------|------|---------|-------------|
418
- | `trustProxy` | `boolean` | `false` | Trust `X-Forwarded-For` and `X-Real-IP` headers |
418
+ | `trustProxy` | `boolean` | `false` | Trust `X-Forwarded-For` and `X-Real-IP` headers. Duplicate `X-Forwarded-For` lines: only the **last** is read (warning in `rust/http/headers.rs`) |
419
419
  | `trustedProxies` | `{ enabled, networks? }` | — | Fine-grained proxy trust with optional network whitelist |
420
420
  | `parseCookies` | `boolean` | `false` | Parse `Cookie` header into structured JSON |
421
421
  | `parseQuery` | `boolean` | `false` | Parse URL query string into structured JSON |
422
422
  | `requireJsonBody` | `boolean` | `false` | Require body to be valid JSON |
423
423
  | `schema` | `Uint8Array` | — | JSON Schema (as UTF-8 bytes) for body validation |
424
- | `cors` | `CorsOptions` | — | CORS configuration |
425
- | `rateLimit` | `{ limit, windowMs, maxEntries }` | — | Rate limiting configuration |
424
+ | `cors` | `CorsOptions` | — | CORS configuration. A present-but-empty `{}` (no `allowOrigin`) resolves to wildcard allow-all |
425
+ | `rateLimit` | `{ limit, windowMs, maxEntries }` | — | Rate limiting configuration. Without a `getIp` resolver every client shares ONE bucket (warns once at router construction) |
426
426
  | `security` | `SecurityHeadersOptions` | — | Security headers configuration |
427
427
  | `https` | `boolean` | — | Force HTTPS detection (overrides auto-detection) |
428
428
  | `maxBodyBytes` | `number` | 1,048,576 | Maximum body size (1 MB) |
@@ -438,7 +438,7 @@ interface SyncIngressHandler {
438
438
 
439
439
  | Option | Type | Default | Description |
440
440
  |--------|------|---------|-------------|
441
- | `allowOrigin` | `string[]` | — | Allowed origins (empty or `["*"]` = wildcard) |
441
+ | `allowOrigin` | `string[]` | — | Allowed origins. Absent, empty, or `["*"]` = wildcard allow-all; list exact origins (or patterns) to narrow |
442
442
  | `allowMethods` | `string[]` | `["GET", "HEAD", "POST"]` | Allowed HTTP methods |
443
443
  | `allowHeaders` | `string[]` | — | Allowed request headers |
444
444
  | `exposeHeaders` | `string[]` | — | Exposed response headers |
package/docs/REPO_MAP.md CHANGED
@@ -37,11 +37,11 @@ Bun is the primary runtime; Node.js ≥20.3 is supported via a compiled ESM entr
37
37
  | Build Rust addon (LOCAL max perf) | `bun run build:perf` | x86-64-v3 + AVX2 — **never for publish** |
38
38
  | Build Rust addon (debug) | `bun run build:debug` | |
39
39
  | Build compiled JS entry (Node) | `bun run build:js` | bundle + types → `dist/` (gitignored) |
40
- | TS unit tests | `bun test` | ~760 tests, `test/unit/**` |
41
- | Rust unit tests | `bun run test:rust` | `cargo test`, ~440 tests |
40
+ | TS unit tests | `bun test test/unit test/property test/compat` | 838 tests (paths are explicit — a bare `bun test` pulls in the Node `.mjs` suites that need `dist/`) |
41
+ | Rust unit tests | `bun run test:rust` | `cargo test`, 628 tests |
42
42
  | Node smoke tests | `bun run test:node` | explicit `.mjs` paths (Node 24 rejects dir arg) |
43
43
  | Installed-tarball e2e | `bun run verify:install` | pack → install → import from `node_modules` |
44
- | Typecheck | `bun run typecheck` | `bunx tsc --noEmit` (only `index.ts`, `bench.ts`, `src/`) |
44
+ | Typecheck | `bun run typecheck` | `bunx tsc --noEmit` (`index.ts`, `bench.ts`, `src/`, `bench/`) |
45
45
  | CPU benchmark + correctness | `bun run check` | == `bun bench.ts`; writes `bench/results/cpu/` |
46
46
  | HTTP bench (all servers) | `bun run bench:http` | |
47
47
  | HTTP smoke (fast sanity) | `bun run bench:http:smoke` | **the wire-format guard** (CI-gated) |
@@ -131,7 +131,7 @@ src/
131
131
  shared.ts JS constants + buildHeaderPlan + assertSyncCallback (shared by both paths).
132
132
  constants.ts Binary-layout constants read from Rust at runtime (SINGLE SOURCE).
133
133
  status.ts / errors.ts / options.ts / types.ts / body.ts / context.ts
134
- packing/ header-packing.ts, input-packer.ts, gather-raw-headers.ts
134
+ packing/ header-packing.ts, input-packer.ts, gather-raw-headers.ts, select-headers.ts, scratch.ts
135
135
  headers/ cors.ts, hsts.ts, security.ts (baked security merge), fast-templates.ts, baked-templates.ts
136
136
  decode/ fast-result.ts, baked-result.ts (TWO decoders — see §4.2)
137
137
  response/ terminal.ts, error-bodies.ts, baked-response.ts (pre-baked response builders)
@@ -178,7 +178,7 @@ rust/ ONE cdylib crate (Cargo [lib] → lib.rs).
178
178
  CASTRUM_TASK_THREADS, batch dequeue + spin-then-park + optional
179
179
  CASTRUM_TASK_PIN_CORES), result ring + batched thread-safe doorbell,
180
180
  op dispatch + cancel + catch_unwind containment + `_into` ops.
181
- ffi/ #[no_mangle] extern "C" exports (119 castrum_* symbols — 107 direct + 4
181
+ ffi/ #[no_mangle] extern "C" exports (121 castrum_* symbols — 109 direct + 4
182
182
  validator_c_abi! + 4 validator_bytes_c_abi! + 4 compress_to_out!; parity guarded by
183
183
  test/unit/native/ffi-symbol-parity.test.ts) for Bun's bun:ffi
184
184
  primary transport, incl. castrum_ingress_layout (the layout blob).
@@ -294,7 +294,9 @@ consumed.
294
294
  - **Multi-platform (recommended)**: push a `v*` tag → `.github/workflows/ci.yml`
295
295
  builds each platform addon, the `publish` job downloads them into
296
296
  `./artifacts`, stages them, and runs `npm publish` (npm Trusted Publishing /
297
- OIDC — no token secret; needs the Trusted Publisher configured on npmjs.com).
297
+ OIDC — preferred, no long-lived token; the job also targets the `NPM_TOKEN`
298
+ environment as a fallback secret. Needs the Trusted Publisher configured on
299
+ npmjs.com).
298
300
  - **Manual single-platform**: `bun run release:manual`
299
301
  (syncs package.json ↔ Cargo.toml ↔ CHANGELOG, tags, builds, publishes with
300
302
  `CASTRUM_PUBLISH_ALLOW_PARTIAL=1`). `--dry-run` plans only.