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.
- package/README.md +153 -630
- package/castrum.linux-x64-gnu.node +0 -0
- package/dist/index.d.ts +1653 -1817
- package/dist/index.js +6172 -4294
- package/docs/API.md +337 -0
- package/docs/ARCHITECTURE.md +111 -17
- package/docs/BENCHMARKS.md +117 -49
- package/docs/CASE_STUDY.md +243 -0
- package/docs/ENVIRONMENT.md +24 -7
- package/docs/FFI_BUN_GUIDE.md +335 -0
- package/docs/GETTING_STARTED.md +18 -34
- package/docs/HOW_TO_ADD_AN_OP.md +180 -0
- package/docs/INGRESS-ROUTER.md +82 -0
- package/docs/INGRESS.md +11 -7
- package/docs/NATIVE-ROUTE.md +77 -0
- package/docs/REPO_MAP.md +103 -53
- package/docs/adr/0001-two-wire-formats.md +1 -1
- package/docs/adr/0003-bun-builtins-delegation.md +6 -5
- package/docs/ai/LOCAL_DEV.md +81 -0
- package/docs/ai/TREE.md +1070 -0
- package/docs/bun-builtins-decision-matrix.md +25 -18
- package/index.ts +9 -16
- package/package.json +41 -28
- package/src/ingress/body.ts +167 -27
- package/src/ingress/decode/result-base.ts +10 -2
- package/src/ingress/handlers.ts +116 -285
- package/src/ingress/headers/security.ts +41 -0
- package/src/ingress/health.ts +1 -0
- package/src/ingress/index.ts +45 -169
- package/src/ingress/native-route.ts +182 -0
- package/src/ingress/packing/gather-raw-headers.ts +110 -22
- package/src/ingress/packing/header-packing.ts +5 -5
- package/src/ingress/packing/input-packer.ts +17 -3
- package/src/ingress/packing/route-wire.ts +197 -0
- package/src/ingress/packing/select-headers.ts +20 -7
- package/src/ingress/path-matcher.ts +115 -0
- package/src/ingress/response/baked-response.ts +302 -0
- package/src/ingress/router.ts +277 -0
- package/src/ingress/routes/common.ts +50 -1
- package/src/ingress/routes/fallback.ts +2 -2
- package/src/ingress/routes/head.ts +3 -13
- package/src/ingress/routes/index.ts +5 -4
- package/src/ingress/routes/json-write.ts +26 -47
- package/src/ingress/routes/native.ts +130 -0
- package/src/ingress/routes/options.ts +2 -12
- package/src/ingress/routes/read.ts +3 -13
- package/src/ingress/routes/responder.ts +159 -0
- package/src/ingress/server-node.ts +7 -7
- package/src/ingress/server.ts +83 -112
- package/src/ingress/sync.ts +151 -0
- package/src/ingress/types.ts +44 -1
- package/src/integration/pipeline.ts +3 -0
- package/src/integration/streaming.ts +2 -3
- package/src/integration/websocket.ts +4 -3
- package/src/loader/batch.ts +4 -0
- package/src/loader/cost.ts +1 -0
- package/src/loader/index.ts +43 -228
- package/src/loader/state.ts +84 -0
- package/src/loader/types.ts +145 -0
- package/src/native/ffi/build/codecs.ts +736 -0
- package/src/native/ffi/build/compress.ts +248 -0
- package/src/native/ffi/build/instances.ts +598 -0
- package/src/native/ffi/build/parse.ts +539 -0
- package/src/native/ffi/build/util.ts +140 -0
- package/src/native/ffi/build.ts +94 -0
- package/src/native/ffi/constants.ts +58 -0
- package/src/native/ffi/selftest.ts +22 -0
- package/src/native/ffi/types.ts +499 -0
- package/src/native/ffi.ts +307 -1357
- package/src/native/loader.ts +52 -6
- package/src/native/types/instances.ts +279 -0
- package/src/native/types.ts +41 -238
- package/src/runtime/builtins.ts +131 -0
- package/src/runtime/codec.ts +147 -0
- package/src/runtime/detect.ts +36 -0
- package/src/runtime/index.ts +28 -0
- package/src/runtime/native.ts +35 -0
- package/src/runtime/server.ts +31 -0
- package/src/runtime/transport.ts +64 -0
- package/src/runtime/types.ts +173 -0
- package/src/runtime/uuid.ts +45 -0
- package/src/runtime/websocket.ts +30 -0
- package/src/rust-ffi/addon.ts +1 -1
- package/src/rust-ffi/batch/build.ts +155 -0
- package/src/rust-ffi/batch/index.ts +8 -0
- package/src/rust-ffi/batch/types.ts +92 -0
- package/src/rust-ffi/client.ts +5 -6
- package/src/rust-ffi/context.ts +39 -10
- package/src/rust-ffi/index.ts +28 -17
- package/src/rust-ffi/into.ts +21 -0
- package/src/rust-ffi/options.ts +1 -1
- package/src/rust-ffi/packed.ts +40 -2
- package/src/rust-ffi/proven.ts +16 -30
- package/src/rust-ffi/scalar/crypto.ts +188 -107
- package/src/rust-ffi/scalar/factories.ts +257 -27
- package/src/rust-ffi/scalar/hashing.ts +43 -41
- package/src/rust-ffi/scalar/http.ts +208 -77
- package/src/rust-ffi/scalar/index.ts +3 -3
- package/src/rust-ffi/scalar/interface.ts +88 -213
- package/src/rust-ffi/scalar/json-packed.ts +175 -0
- package/src/rust-ffi/scalar/json.ts +28 -12
- package/src/rust-ffi/scalar/payload.ts +159 -42
- package/src/rust-ffi/text.ts +31 -5
- package/src/selection.ts +27 -31
- package/src/shared/adaptive.ts +1 -2
- package/src/shared/buffer-pool.ts +72 -17
- package/src/shared/bytes.ts +75 -6
- package/src/shared/codec.ts +78 -0
- package/src/shared/env.ts +4 -4
- package/src/shared/log.ts +1 -0
- package/src/shared/packed/index.ts +10 -0
- package/src/shared/packed/parsers.ts +31 -0
- package/src/shared/packed/schema.ts +23 -0
- package/src/shared/{packed.ts → packed/wire.ts} +31 -39
- package/src/shared/proven.ts +189 -496
- package/src/shared/runtime.ts +20 -25
- package/src/shared/uuid.ts +12 -28
- package/src/data/json-rows.ts +0 -33
- package/src/rust-ffi/batch.ts +0 -431
- 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
|
|
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
|
-
|
|
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
|
|
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.
|
|
67
|
-
| `gzipCompress`
|
|
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
|
-
| `
|
|
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
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
### →
|
|
87
|
-
|
|
|
93
|
+
### → Implemented primitives (gaps closed)
|
|
94
|
+
| Primitive | Outcome |
|
|
88
95
|
|---|---|
|
|
89
|
-
|
|
|
90
|
-
|
|
|
91
|
-
|
|
|
92
|
-
| UUIDv7 | `Bun.randomUUIDv7` already wins —
|
|
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 —
|
|
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
|
-
//
|
|
6
|
-
//
|
|
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.
|
|
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
|
-
"
|
|
80
|
-
"
|
|
81
|
-
"
|
|
82
|
-
"
|
|
83
|
-
"
|
|
84
|
-
"bench:http": "bun run bench/run-bench.ts",
|
|
85
|
-
"bench:http:
|
|
86
|
-
"bench:http:
|
|
87
|
-
"bench:http:
|
|
88
|
-
"bench:http:
|
|
89
|
-
"bench:http:
|
|
90
|
-
"bench:http:
|
|
91
|
-
"bench:http:
|
|
92
|
-
"bench:http:
|
|
93
|
-
"bench:http:
|
|
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:
|
|
99
|
-
"bench:ffi
|
|
100
|
-
"bench:ffi:
|
|
101
|
-
"bench:ffi:
|
|
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": "
|
|
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.
|
|
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.
|
|
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",
|
package/src/ingress/body.ts
CHANGED
|
@@ -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
|
|
60
|
-
// reader + per-chunk `Promise.race` + `concatUint8Arrays`
|
|
61
|
-
// POSTs are single-shot bodies — this removes ~4-6
|
|
62
|
-
// request). The deadline (when set) is still enforced
|
|
63
|
-
// not one per chunk.
|
|
64
|
-
// `maxRequestBodySize`, and the post-read length re-check catches
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
114
|
-
|
|
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
|