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
@@ -2,7 +2,7 @@
2
2
 
3
3
  > **Question this answers**: for the same workload, does a Bun native built-in
4
4
  > beat the castrum Rust op? Where it does, castrum should stop reinventing the
5
- > wheel and `@flux/native` should delegate to Bun; where castrum wins (or Bun
5
+ > wheel and delegate to Bun; where castrum wins (or Bun
6
6
  > has no sync equivalent), the Rust op stands.
7
7
 
8
8
  ## Methodology
@@ -12,9 +12,8 @@
12
12
  shipped profile).
13
13
  - **Driver**: `bun run check` (CPU benchmark → `bench/results/cpu/latest.json`).
14
14
  Each pair runs on the SAME input (fixture payloads, see table).
15
- - **Naming**: diagnostic comparisons use `diag:` task names so
16
- `scripts/check-proven.ts` never aggregates them into a `PROVEN_SURFACE`
17
- entry (see `src/bench/comparisons.ts`).
15
+ - **Naming**: diagnostic comparisons use `diag:` task names so they are kept out
16
+ of the shipped-op `native:`/`rust:` comparisons (see `src/bench/comparisons.ts`).
18
17
  - **Honesty guard**: every Bun API is verified to actually execute real work at
19
18
  runtime before a result is trusted. This is important — the first run
20
19
  produced false "wins" (brotli 156×, validators 3–5×) because this Bun
@@ -56,22 +55,30 @@
56
55
 
57
56
  ## Decisions
58
57
 
59
- ### → Delegate to Bun at the `@flux/native` wrapper layer (Bun wins)
58
+ ### → Delegate to Bun at the selection layer (Bun wins)
60
59
  These rust ops lose to Bun's native implementation on the SAME workload — the
61
60
  FFI crossing cannot beat Bun's in-process C++:
62
61
 
62
+ This table mirrors `BUILTIN_OPS` in `src/runtime/builtins.ts` (the 11 ops that
63
+ delegate to Bun built-ins under Bun; `src/selection.ts` derives from it). `fnv1a64` is parity/either but is NOT delegated
64
+ (`Bun.hash` is wyhash — a different algorithm — and rust `fnv1a64` stays the
65
+ benchmarked impl).
66
+
63
67
  | Op | Recommendation |
64
68
  |---|---|
65
69
  | `crc32` | Prefer `Bun.hash.crc32`; keep rust as fallback (pure-TS path already exists). |
66
- | `xxh3` | Prefer `Bun.hash.xxHash3` (~4×); keep the rust export as the Node/non-Bun fast path. Classified `not-competitive` in `PROVEN_SURFACE`. |
67
- | `gzipCompress` / `gzipDecompress` | Prefer `Bun.gzipSync` / `Bun.gunzipSync`. |
70
+ | `xxh3` | Prefer `Bun.hash.xxHash3` (~4×); keep the rust export as the Node/non-Bun fast path. The runtime adapter delegates the public `rust.xxh3` to Bun under Bun. |
71
+ | `gzipCompress` | Prefer `Bun.gzipSync` (~2×). **`gzipDecompress` is deliberately NOT delegated** — `Bun.gunzipSync` has no decompression-bomb cap; the rust surface keeps its native 64 MiB-capped path under Bun. |
68
72
  | `randomToken` | Prefer `Bun.randomUUIDv7()` (or `crypto.getRandomValues`) for token-sized output; keep rust for byte-precise control. |
69
73
  | `hmacSha256` | Prefer `Bun.CryptoHasher("sha256", key)` (1.17× — mild; keep rust batch path, which wins on larger inputs). |
70
- | `fnv1a64` | Parity — either is fine; `Bun.hash` (wyhash) is a stronger algorithm, prefer it for NEW non-crypto hashing needs. |
74
+ | `urlEncode` / `urlDecode` | Prefer `encodeURIComponent` / `decodeURIComponent` (JSC string builtins — ~11.5×, zero alloc). |
75
+ | `base64Encode` / `base64UrlEncode` | Prefer `Buffer.toString('base64'|'base64url')`. |
76
+ | `hexEncode` | Prefer `Buffer.toString('hex')`. |
77
+ | `httpDate` | Prefer `Date.toUTCString()`. |
71
78
 
72
- **Delegation rule of thumb**: rust stays the default only where it is
73
- `proven` against its **Bun** baseline (not just its JS baseline). These are the
74
- ops where rust genuinely wins or Bun has no equivalent.
79
+ **Delegation rule of thumb**: rust stays the default only where it beats its
80
+ **Bun** baseline (not just its JS baseline). These are the ops where rust
81
+ genuinely wins or Bun has no equivalent.
75
82
 
76
83
  ### → Keep rust (rust wins or Bun has no equivalent)
77
84
  | Op | Rationale |
@@ -83,19 +90,19 @@ ops where rust genuinely wins or Bun has no equivalent.
83
90
  | `validateEmail` / `validateUuid` / `validateIpv4` / `validateIpv6` | `Bun.validators` absent in 1.4; keep rust. Re-check when Bun adds it. |
84
91
  | `jsonValid` / `jsonSumIds` / packed parsers / ingress | No Bun sync equivalent for the zero-DOM / zero-copy semantics; keep rust. |
85
92
 
86
- ### → Add primitives that close gaps (Phase 2)
87
- | Gap | Plan |
93
+ ### → Implemented primitives (gaps closed)
94
+ | Primitive | Outcome |
88
95
  |---|---|
89
- | Public XXH3-64 (`rust.xxh3`) | **Done** — exposed; measured Bun.hash.xxHash3 4.15× faster → classify `not-competitive`, prefer Bun under Bun. |
90
- | bcrypt | **Done** — `rust.passwordHashBcrypt`/`passwordVerifyBcrypt`; hash parity, verify rust 1.49×. |
91
- | PBKDF2 | **Done** — `rust.pbkdf2Sha256`; parity with node:crypto, only sync option in Bun. |
92
- | UUIDv7 | `Bun.randomUUIDv7` already wins — **do not** build a rust UUIDv7; use Bun. |
96
+ | `rust.xxh3` (XXH3-64) | Exposed; measured `Bun.hash.xxHash3` 4.15× faster → the runtime adapter delegates the public `rust.xxh3` to Bun under Bun (Node keeps the addon). |
97
+ | `rust.passwordHashBcrypt` / `rust.passwordVerifyBcrypt` | bcrypt `$2b$` PHC; hash parity, verify rust 1.49×. |
98
+ | `rust.pbkdf2Sha256` | PBKDF2-HMAC-SHA256; parity with `node:crypto`, the only sync option in Bun. |
99
+ | UUIDv7 (`uuidv7()`) | `Bun.randomUUIDv7` already wins — delegated to Bun (`crypto.randomUUID` on Node), not built in Rust. |
93
100
 
94
101
  ## Summary
95
102
 
96
103
  **The "don't reinvent the wheel" takeaway:** for non-cryptographic hashing,
97
104
  random tokens, and gzip, Bun 1.4's built-ins are already faster than any
98
- FFI-crossing Rust op — `@flux/native` should prefer them. Rust retains a clear,
105
+ FFI-crossing Rust op — the selection layer should prefer them. Rust retains a clear,
99
106
  defensible moat exactly where it matters: **argon2id password hashing
100
107
  (≈1.9× vs Bun)**, the **zero-DOM / zero-copy parsers and JSON paths** (no Bun
101
108
  sync equivalent), and **brotli** (no Bun sync API).
package/index.ts CHANGED
@@ -1,9 +1,15 @@
1
1
  // index.ts — castrum package entry (barrel).
2
2
  //
3
3
  // Re-exports the full public API: the flat `rust.*` FFI surface
4
- // (src/rust-ffi), the runtime selection hints (src/selection), the
5
- // performance-proven registry (src/shared/proven), and the higher-order
6
- // loader (src/loader). See AGENTS.md for the layout map.
4
+ // (src/rust-ffi), the runtime selection hints (src/selection), and the
5
+ // higher-order loader (src/loader). See AGENTS.md for the layout map.
6
+ //
7
+ // EAGER-DLOPEN CONTRACT: this barrel unconditionally re-exports `src/ingress`
8
+ // → `src/ingress/constants.ts`, which is the ONE module that touches native at
9
+ // import time (it reads the Rust binary layout and runs the bind-time
10
+ // self-test). This is intentional — even `import { rust }` pays the eager
11
+ // load. Do NOT add more eager-native modules; keep everything else lazy (see
12
+ // test/unit/features/import-contract.test.ts).
7
13
 
8
14
  export * from './src/rust-ffi'
9
15
 
@@ -15,19 +21,6 @@ export * from './src/rust-ffi'
15
21
  export { opImpl, isNativeOp, opDecision } from './src/selection'
16
22
  export type { OpImpl, OpDecision } from './src/selection'
17
23
 
18
- // Performance-proven surface: only `rust.*` functions that beat their JS
19
- // baseline in benchmarks. See src/shared/proven.ts for the registry.
20
- export {
21
- proven,
22
- PROVEN_SURFACE,
23
- provenStatus,
24
- isProven,
25
- provenSurface,
26
- provenSummary,
27
- } from './src/rust-ffi/proven'
28
- export type { ProvenClient, ProvenKey } from './src/rust-ffi/proven'
29
- export type { PerformanceStatus, ProvenEntry } from './src/shared/proven'
30
-
31
24
  export { encoder, decoder } from './src/shared/bytes'
32
25
  // Bounded EWMA adaptive-estimate utility (drives BufferPool adaptive sizing
33
26
  // and reusable runtime self-optimization decisions).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "castrum",
3
- "version": "0.9.0",
3
+ "version": "0.9.2",
4
4
  "private": false,
5
5
  "description": "Enterprise-grade Bun + Rust runtime: Rust-accelerated FFI primitives, a production-grade HTTP ingress pipeline, validation, hashing, and JSON processing with benchmark-grade performance.",
6
6
  "type": "module",
@@ -25,6 +25,10 @@
25
25
  "type": "git",
26
26
  "url": "git+https://github.com/Axylabs/flux-rs.git"
27
27
  },
28
+ "homepage": "https://github.com/Axylabs/flux-rs#readme",
29
+ "bugs": {
30
+ "url": "https://github.com/Axylabs/flux-rs/issues"
31
+ },
28
32
  "publishConfig": {
29
33
  "access": "public"
30
34
  },
@@ -45,18 +49,21 @@
45
49
  "README.md",
46
50
  "*.node",
47
51
  "!src/bench",
48
- "!src/baseline"
52
+ "!src/baseline",
53
+ "!src/data"
49
54
  ],
50
55
  "scripts": {
51
56
  "build": "napi build --release --platform",
52
57
  "build:perf": "bash scripts/build-perf.sh",
58
+ "build:v3": "bash scripts/build-v3.sh",
59
+ "build:all": "bun run build && bun run build:v3",
53
60
  "build:js": "bun run build:js:bundle && bun run build:js:types",
54
61
  "build:js:bundle": "bun build ./index.ts --target=node --format=esm --outdir=dist",
55
62
  "build:js:types": "dts-bundle-generator -o dist/index.d.ts index.ts",
56
63
  "bench": "bun run build && bun bench.ts",
57
64
  "check": "bun bench.ts",
58
65
  "test": "bun test",
59
- "test:all": "bun test && bun run typecheck && bun run typecheck:test && bun run lint:ci && cargo test --lib",
66
+ "test:all": "bun test && bun run typecheck && bun run typecheck:test && bun run lint:ci && bun run check:clean && cargo test --lib",
60
67
  "test:coverage": "node scripts/check-coverage.mjs",
61
68
  "setup": "bun install && bun run build",
62
69
  "test:node": "node --test test/integration/node-smoke.test.mjs test/integration/node-enterprise.test.mjs",
@@ -72,38 +79,43 @@
72
79
  "deny": "cargo deny check",
73
80
  "check:version": "bun scripts/check-version.mjs",
74
81
  "check:jsdoc": "bun scripts/check-jsdoc.ts",
82
+ "check:selection": "bun scripts/select-native.ts --check",
83
+ "check:clean": "bun scripts/check-clean.ts",
84
+ "gen:ai-map": "bun scripts/gen-ai-map.ts",
75
85
  "audit": "bun audit",
76
86
  "publish:manual": "node scripts/publish-manual.mjs",
77
87
  "publish:manual:dry": "node scripts/publish-manual.mjs --dry-run",
78
88
  "prepublishOnly": "bun run build:js && node scripts/prepublish.mjs",
79
- "check:proven": "bun scripts/check-proven.ts",
80
- "check:proven:fail": "bun scripts/check-proven.ts --fail",
81
- "check:annotate": "bun scripts/annotate-performance.ts",
82
- "check:annotate:dry": "bun scripts/annotate-performance.ts --dry-run",
83
- "check:docs": "bun run check && bun run check:annotate && bun run check:proven",
84
- "bench:http": "bun run bench/run-bench.ts",
85
- "bench:http:smoke": "bun run bench/run-bench.ts smoke",
86
- "bench:http:stress": "bun run bench/run-bench.ts stress",
87
- "bench:http:heavy": "bun run bench/run-bench.ts heavy-json",
88
- "bench:http:crud": "bun run bench/run-bench.ts crud",
89
- "bench:http:spike": "bun run bench/run-bench.ts spike",
90
- "bench:http:soak": "bun run bench/run-bench.ts soak",
91
- "bench:http:storm": "bun run bench/run-bench.ts storm",
92
- "bench:http:boundary": "bun run bench/run-bench.ts boundary",
93
- "bench:http:elysia": "bun run bench/run-bench.ts '' elysia",
94
- "bench:http:ingress": "bun run bench/run-bench.ts '' ingress",
95
- "bench:http:bun-only": "bun run bench/run-bench.ts '' bun",
96
- "bench:http:all-heavy": "bun run bench/run-bench.ts 1",
89
+ "bench:http": "BUN_CONFIG_MAX_HTTP_REQUESTS=65535 bun run bench/http/run-bench.ts",
90
+ "bench:http:smoke": "BUN_CONFIG_MAX_HTTP_REQUESTS=65535 bun run bench/http/run-bench.ts smoke",
91
+ "bench:http:stress": "BUN_CONFIG_MAX_HTTP_REQUESTS=65535 bun run bench/http/run-bench.ts stress",
92
+ "bench:http:heavy": "BUN_CONFIG_MAX_HTTP_REQUESTS=65535 bun run bench/http/run-bench.ts heavy-json",
93
+ "bench:http:crud": "BUN_CONFIG_MAX_HTTP_REQUESTS=65535 bun run bench/http/run-bench.ts crud",
94
+ "bench:http:spike": "BUN_CONFIG_MAX_HTTP_REQUESTS=65535 bun run bench/http/run-bench.ts spike",
95
+ "bench:http:soak": "BUN_CONFIG_MAX_HTTP_REQUESTS=65535 bun run bench/http/run-bench.ts soak",
96
+ "bench:http:storm": "BUN_CONFIG_MAX_HTTP_REQUESTS=65535 bun run bench/http/run-bench.ts storm",
97
+ "bench:http:boundary": "BUN_CONFIG_MAX_HTTP_REQUESTS=65535 bun run bench/http/run-bench.ts boundary",
98
+ "bench:http:elysia": "BUN_CONFIG_MAX_HTTP_REQUESTS=65535 bun run bench/http/run-bench.ts '' elysia",
99
+ "bench:http:ingress": "BUN_CONFIG_MAX_HTTP_REQUESTS=65535 bun run bench/http/run-bench.ts '' ingress",
100
+ "bench:http:bun-only": "BUN_CONFIG_MAX_HTTP_REQUESTS=65535 bun run bench/http/run-bench.ts '' bun",
101
+ "bench:http:all-heavy": "BUN_CONFIG_MAX_HTTP_REQUESTS=65535 bun run bench/http/run-bench.ts 1",
102
+ "bench:http:ac": "bun bench/autocannon-stress.mjs",
103
+ "bench:http:stress:ac": "SCENARIO=03-stress bun bench/autocannon-stress.mjs",
97
104
  "bench:startup": "bun run bench/startup.ts",
98
- "bench:ffi": "bun run bench/ffi-all.ts",
99
- "bench:ffi:load": "bun run bench/ffi-load.ts",
100
- "bench:ffi:public": "bun run bench/ffi-public.ts",
101
- "bench:ffi:workers": "bun run bench/ffi-workers.ts",
105
+ "bench:load": "bun run bench/http/load-phase.ts",
106
+ "bench:ffi": "bun run bench/ffi/ffi-all.ts",
107
+ "bench:ffi:load": "bun run bench/ffi/ffi-load.ts",
108
+ "bench:ffi:public": "bun run bench/ffi/ffi-public.ts",
109
+ "bench:ffi:workers": "bun run bench/ffi/ffi-workers.ts",
110
+ "bench:margin": "bun run bench/ffi/ffi-margin.ts",
111
+ "bench:router": "bun run bench/cost/router-cost.ts",
112
+ "bench:ingress-cost": "bun run bench/cost/ingress-cost.ts",
113
+ "bench:ingress-cost:post": "bun run bench/cost/ingress-cost-post.ts",
102
114
  "verify:native:batch": "bun run scripts/verify-native-batch.ts"
103
115
  },
104
116
  "dependencies": {},
105
117
  "peerDependencies": {
106
- "@types/node": ">=20"
118
+ "@types/node": "^26.1.2"
107
119
  },
108
120
  "peerDependenciesMeta": {
109
121
  "@types/node": {
@@ -112,14 +124,15 @@
112
124
  },
113
125
  "devDependencies": {
114
126
  "@elysia/cors": "^1.4.2",
115
- "@napi-rs/cli": "^3.7.4",
127
+ "@napi-rs/cli": "^3.8.6",
116
128
  "@types/bun": "^1.3.14",
117
129
  "@types/mime-types": "^3.0.1",
130
+ "autocannon": "^8.0.0",
118
131
  "@types/validator": "^13.15.10",
119
132
  "ajv": "^8.20.0",
120
133
  "crc-32": "^1.2.2",
121
134
  "dts-bundle-generator": "^9.5.1",
122
- "elysia": "^1.4.0",
135
+ "elysia": "^1.4.29",
123
136
  "fast-json-patch": "^3.1.1",
124
137
  "mime-types": "^3.0.2",
125
138
  "typescript": "5.9.3",
@@ -1,8 +1,50 @@
1
1
  // src/ingress/body.ts — Streaming request-body reading for the async ingress
2
2
  // API.
3
3
 
4
+ import { isBun } from '../shared/runtime'
5
+
4
6
  const EMPTY_BODY = new Uint8Array(0)
5
7
 
8
+ /**
9
+ * Read the request body as bytes. Bun's `req.bytes()` returns a `Uint8Array`
10
+ * directly (no `ArrayBuffer` → view wrapper and no copy) — feature-detected so
11
+ * Node (which lacks `Response.bytes()` on some versions) falls back to
12
+ * `arrayBuffer()` + a zero-copy view. This is the hot-path body read for every
13
+ * write route.
14
+ */
15
+ async function readBodyBytes(req: Request): Promise<Uint8Array> {
16
+ const bytes = (req as { bytes?: () => Promise<Uint8Array> }).bytes
17
+ if (typeof bytes === 'function') {
18
+ return bytes.call(req)
19
+ }
20
+ return new Uint8Array(await req.arrayBuffer())
21
+ }
22
+
23
+ /**
24
+ * Bun-only synchronous peek of an already-buffered body.
25
+ *
26
+ * Under Bun a body fully received during HTTP parsing resolves `req.bytes()`
27
+ * IMMEDIATELY — `Bun.peek` hands back the `Uint8Array` without awaiting, so a
28
+ * declared-Content-Length write route can read the body with no deadline race,
29
+ * no watchdog entry and no microtask turn (the common small-body case).
30
+ *
31
+ * Returns:
32
+ * - `{ bytes }` — already buffered; the caller reads it synchronously and
33
+ * must NOT pay the deadline race (the body is already here).
34
+ * - `Promise<Uint8Array>` — the read is still pending (slow/trickling body);
35
+ * the caller MUST still enforce its deadline against this promise.
36
+ * - `false` — no `req.bytes()` on this runtime (Node); the caller falls back
37
+ * to the async `readBodyBytes` + race as before.
38
+ */
39
+ function peekBufferedBody(req: Request): { bytes: Uint8Array } | Promise<Uint8Array> | false {
40
+ const bytes = (req as { bytes?: () => Promise<Uint8Array> }).bytes
41
+ if (typeof bytes !== 'function') return false
42
+ const pending = bytes.call(req)
43
+ if (!isBun()) return pending
44
+ const peeked = Bun.peek(pending)
45
+ return peeked instanceof Promise ? peeked : { bytes: peeked }
46
+ }
47
+
6
48
  /** Read the request body once, enforcing the size guard when enabled. */
7
49
  export function readRequestBodyOnce(
8
50
  req: Request,
@@ -23,6 +65,77 @@ function bodyTooLargeError(): Error & { code?: string } {
23
65
  return err
24
66
  }
25
67
 
68
+ // ── Shared body-read deadline watchdog ──────────────────────────────────────
69
+ // One interval timer sweeps ALL in-flight body reads that carry a deadline,
70
+ // replacing a per-request `setTimeout` + `clearTimeout` + `timer.unref` on
71
+ // every write-route body read (that per-request timer alloc is measurable in
72
+ // the POST micro-bench — ~800ns of the ~2.3µs body read). Abort semantics are
73
+ // unchanged: a read that outlives its deadline rejects with REQUEST_TIMEOUT.
74
+ // Entries are removed on completion or when the deadline fires; the interval
75
+ // only runs while at least one read is pending.
76
+ const bodyWatchdog = (() => {
77
+ const inflight = new Set<WatchdogEntry>()
78
+ let timer: ReturnType<typeof setInterval> | null = null
79
+ const SWEEP_MS = 250
80
+
81
+ function sweep() {
82
+ const now = Date.now()
83
+ for (const entry of inflight) {
84
+ if (entry.settled) {
85
+ inflight.delete(entry)
86
+ continue
87
+ }
88
+ if (now >= entry.deadlineWall) {
89
+ entry.settled = true
90
+ inflight.delete(entry)
91
+ entry.reject(timeoutError())
92
+ }
93
+ }
94
+ if (inflight.size === 0 && timer !== null) {
95
+ clearInterval(timer)
96
+ timer = null
97
+ }
98
+ }
99
+
100
+ function track(entry: WatchdogEntry) {
101
+ inflight.add(entry)
102
+ if (timer === null) {
103
+ timer = setInterval(sweep, SWEEP_MS)
104
+ }
105
+ }
106
+
107
+ function untrack(entry: WatchdogEntry) {
108
+ entry.settled = true
109
+ inflight.delete(entry)
110
+ if (inflight.size === 0 && timer !== null) {
111
+ clearInterval(timer)
112
+ timer = null
113
+ }
114
+ }
115
+
116
+ return { track, untrack }
117
+ })()
118
+
119
+ interface WatchdogEntry {
120
+ deadlineWall: number
121
+ settled: boolean
122
+ reject: (err: Error) => void
123
+ }
124
+
125
+ /** Create a deadline tracked by the shared watchdog (no per-read setTimeout). */
126
+ function createDeadline(deadlineMs: number): { promise: Promise<never>; cancel: () => void } {
127
+ const entry: WatchdogEntry = {
128
+ deadlineWall: Date.now() + deadlineMs,
129
+ settled: false,
130
+ reject: () => {},
131
+ }
132
+ const promise = new Promise<never>((_resolve, reject) => {
133
+ entry.reject = reject
134
+ })
135
+ bodyWatchdog.track(entry)
136
+ return { promise, cancel: () => bodyWatchdog.untrack(entry) }
137
+ }
138
+
26
139
  /**
27
140
  * Stream-read the request body, enforcing the size guard when enabled and an
28
141
  * optional overall deadline.
@@ -39,30 +152,16 @@ export async function readBodyWithLimit(
39
152
  guard: boolean,
40
153
  timeoutMs?: number,
41
154
  ): Promise<Uint8Array> {
42
- const body = req.body
43
-
44
- if (!body) {
45
- return EMPTY_BODY
46
- }
47
-
48
155
  const deadline = timeoutMs && timeoutMs > 0 ? timeoutMs : 0
49
- let timer: ReturnType<typeof setTimeout> | undefined
50
- const timeout = new Promise<never>((_resolve, reject) => {
51
- if (deadline <= 0) return
52
- timer = setTimeout(() => reject(timeoutError()), deadline)
53
- // Don't let a pending body timeout keep the event loop alive once the
54
- // request has otherwise completed (Node; a no-op under Bun).
55
- timer.unref?.()
56
- })
57
156
 
58
157
  // Fast path: a declared Content-Length lets us PROVE the body fits the
59
- // guard before reading, so one native `arrayBuffer()` read replaces the
60
- // reader + per-chunk `Promise.race` + `concatUint8Arrays` churn (the bench
61
- // POSTs are single-shot bodies — this removes ~4-6 allocations per write
62
- // request). The deadline (when set) is still enforced with a single race,
63
- // not one per chunk. `req.arrayBuffer()` is bounded by the server's
64
- // `maxRequestBodySize`, and the post-read length re-check catches a client
65
- // that lies about Content-Length.
158
+ // guard before reading, so one native `bytes()`/`arrayBuffer()` read
159
+ // replaces the reader + per-chunk `Promise.race` + `concatUint8Arrays`
160
+ // churn (the bench POSTs are single-shot bodies — this removes ~4-6
161
+ // allocations per write request). The deadline (when set) is still enforced
162
+ // with a single race, not one per chunk. The read is bounded by the
163
+ // server's `maxRequestBodySize`, and the post-read length re-check catches
164
+ // a client that lies about Content-Length.
66
165
  const declared = req.headers.get('content-length')
67
166
  if (declared !== null) {
68
167
  const declaredLen = Number(declared)
@@ -70,20 +169,60 @@ export async function readBodyWithLimit(
70
169
  if (guard && declaredLen > maxBytes) {
71
170
  throw bodyTooLargeError()
72
171
  }
172
+ // `req.body` (the stream getter) is deliberately NOT touched on this
173
+ // path — `req.bytes()` (Bun) / `arrayBuffer()` (Node) read the buffered
174
+ // body directly, avoiding a lazily-constructed stream per write request.
175
+ if (deadline <= 0) {
176
+ const bytes = await readBodyBytes(req)
177
+ if (guard && bytes.byteLength > maxBytes) {
178
+ throw bodyTooLargeError()
179
+ }
180
+ return bytes
181
+ }
182
+ // Deadline path. Under Bun, a small declared-length body is usually
183
+ // already buffered — `Bun.peek` returns the bytes synchronously, so we
184
+ // skip the deadline race + watchdog + microtask entirely for the common
185
+ // case. Only a genuinely pending read (slow/trickling body) pays the
186
+ // race; non-Bun runtimes keep the previous behavior.
187
+ const buffered = peekBufferedBody(req)
188
+ if (buffered === false) {
189
+ const dl = createDeadline(deadline)
190
+ try {
191
+ const bytes = await Promise.race([readBodyBytes(req), dl.promise])
192
+ if (guard && bytes.byteLength > maxBytes) {
193
+ throw bodyTooLargeError()
194
+ }
195
+ return bytes
196
+ } finally {
197
+ dl.cancel()
198
+ }
199
+ }
200
+ if ('bytes' in buffered) {
201
+ if (guard && buffered.bytes.byteLength > maxBytes) {
202
+ throw bodyTooLargeError()
203
+ }
204
+ return buffered.bytes
205
+ }
206
+ const dl = createDeadline(deadline)
73
207
  try {
74
- const buf =
75
- deadline > 0 ? await Promise.race([req.arrayBuffer(), timeout]) : await req.arrayBuffer()
76
- const bytes = new Uint8Array(buf)
208
+ const bytes = await Promise.race([buffered, dl.promise])
77
209
  if (guard && bytes.byteLength > maxBytes) {
78
210
  throw bodyTooLargeError()
79
211
  }
80
212
  return bytes
81
213
  } finally {
82
- clearTimeout(timer)
214
+ dl.cancel()
83
215
  }
84
216
  }
85
217
  }
86
218
 
219
+ // No (usable) Content-Length: fall back to the streaming reader. `req.body`
220
+ // is only touched here — the declared-length fast path never needs it.
221
+ const body = req.body
222
+ if (!body) {
223
+ return EMPTY_BODY
224
+ }
225
+
87
226
  const reader = body.getReader()
88
227
  const chunks: Uint8Array[] = []
89
228
  let total = 0
@@ -91,11 +230,12 @@ export async function readBodyWithLimit(
91
230
  // already closed, so the `cancel()` in `finally` is a wasted async round-trip
92
231
  // per request. It is still required when the loop exits early (timeout/error).
93
232
  let completed = false
233
+ const dl = deadline > 0 ? createDeadline(deadline) : null
94
234
 
95
235
  try {
96
236
  for (;;) {
97
237
  const { done, value } =
98
- deadline > 0 ? await Promise.race([reader.read(), timeout]) : await reader.read()
238
+ dl !== null ? await Promise.race([reader.read(), dl.promise]) : await reader.read()
99
239
 
100
240
  if (done) {
101
241
  completed = true
@@ -112,7 +252,7 @@ export async function readBodyWithLimit(
112
252
  chunks.push(value)
113
253
  }
114
254
  } finally {
115
- clearTimeout(timer)
255
+ dl?.cancel()
116
256
  // Release the underlying stream. Cancelling while a read() is still
117
257
  // pending (the timeout case) can resolve that pending read() as `done`
118
258
  // and RACE the timeout rejection — so reject first, cancel only AFTER
@@ -110,8 +110,16 @@ export abstract class IngressResultBase {
110
110
  */
111
111
  protected setRateWindow(rateLimit: number, rateLimited: boolean, view: DataView): void {
112
112
  if (rateLimit > 0 || rateLimited) {
113
- this.rateResetMs = Number(view.getBigUint64(OUT_RATE_RESET, true))
114
- this.retryAfterMs = Number(view.getBigUint64(OUT_RETRY_AFTER, true))
113
+ // i64 LE read as two u32 halves (lo + hi * 2^32) instead of
114
+ // `getBigUint64` — avoids the per-read BigInt boxing (measured ~10ns
115
+ // per read on the hot path). Bit-identical to `Number(getBigUint64(..))`
116
+ // for the unsigned interpretation and exact for epoch-ms (< 2^53).
117
+ const resetLo = view.getUint32(OUT_RATE_RESET, true)
118
+ const resetHi = view.getUint32(OUT_RATE_RESET + 4, true)
119
+ this.rateResetMs = resetLo + resetHi * 4294967296
120
+ const retryLo = view.getUint32(OUT_RETRY_AFTER, true)
121
+ const retryHi = view.getUint32(OUT_RETRY_AFTER + 4, true)
122
+ this.retryAfterMs = retryLo + retryHi * 4294967296
115
123
  } else {
116
124
  this.rateResetMs = 0
117
125
  this.retryAfterMs = 0