castrum 0.7.0 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +154 -368
- package/castrum.linux-x64-gnu.node +0 -0
- package/dist/index.d.ts +2763 -0
- package/dist/index.js +8707 -0
- package/docs/API.md +337 -0
- package/docs/ARCHITECTURE.md +507 -0
- package/docs/BENCHMARKS.md +343 -0
- package/docs/CASE_STUDY.md +240 -0
- package/docs/ENVIRONMENT.md +81 -0
- package/docs/FFI_BUN_GUIDE.md +327 -0
- package/docs/GETTING_STARTED.md +192 -0
- package/docs/INGRESS-ROUTER.md +81 -0
- package/docs/INGRESS.md +746 -0
- package/docs/NATIVE-ROUTE.md +53 -0
- package/docs/REPO_MAP.md +279 -0
- package/docs/adr/0001-two-wire-formats.md +39 -0
- package/docs/adr/0002-zero-dom-fast-schema.md +38 -0
- package/docs/adr/0003-bun-builtins-delegation.md +43 -0
- package/docs/bun-builtins-decision-matrix.md +115 -0
- package/index.ts +44 -8
- package/package.json +98 -33
- package/src/ingress/body.ts +289 -0
- package/src/ingress/constants.ts +220 -48
- package/src/ingress/context.ts +136 -0
- package/src/ingress/decode/baked-result.ts +121 -0
- package/src/ingress/decode/fast-result.ts +160 -0
- package/src/ingress/decode/packed-sections.ts +56 -0
- package/src/ingress/decode/result-base.ts +172 -0
- package/src/ingress/errors.ts +72 -0
- package/src/ingress/fast.ts +152 -1021
- package/src/ingress/handlers.ts +639 -1001
- package/src/ingress/headers/baked-templates.ts +102 -0
- package/src/ingress/headers/cors.ts +44 -0
- package/src/ingress/headers/fast-templates.ts +238 -0
- package/src/ingress/headers/hsts.ts +83 -0
- package/src/ingress/health.ts +68 -0
- package/src/ingress/index.ts +142 -405
- package/src/ingress/metrics.ts +132 -0
- package/src/ingress/options.ts +197 -0
- package/src/ingress/packing/gather-raw-headers.ts +139 -0
- package/src/ingress/packing/header-packing.ts +75 -0
- package/src/ingress/packing/input-packer.ts +164 -0
- package/src/ingress/packing/scratch.ts +50 -0
- package/src/ingress/packing/select-headers.ts +102 -0
- package/src/ingress/response/error-bodies.ts +75 -0
- package/src/ingress/response/terminal.ts +59 -0
- package/src/ingress/router.ts +237 -0
- package/src/ingress/routes/common.ts +79 -0
- package/src/ingress/routes/delete.ts +14 -0
- package/src/ingress/routes/echo.ts +107 -0
- package/src/ingress/routes/fallback.ts +23 -0
- package/src/ingress/routes/head.ts +27 -0
- package/src/ingress/routes/index.ts +11 -0
- package/src/ingress/routes/json-write.ts +129 -0
- package/src/ingress/routes/options.ts +26 -0
- package/src/ingress/routes/read.ts +35 -0
- package/src/ingress/routes/responder.ts +159 -0
- package/src/ingress/server-node.ts +376 -0
- package/src/ingress/server.ts +475 -0
- package/src/ingress/shared.ts +132 -0
- package/src/ingress/status.ts +97 -0
- package/src/ingress/types.ts +191 -0
- package/src/integration/batch.ts +58 -0
- package/src/integration/index.ts +29 -0
- package/src/integration/pipeline.ts +220 -0
- package/src/integration/streaming.ts +78 -0
- package/src/integration/websocket.ts +93 -0
- package/src/loader/batch.ts +137 -0
- package/src/loader/cost.ts +105 -0
- package/src/loader/index.ts +745 -0
- package/src/loader/ops.ts +657 -0
- package/src/loader/state.ts +84 -0
- package/src/loader/types.ts +145 -0
- package/src/native/ffi/constants.ts +58 -0
- package/src/native/ffi/selftest.ts +729 -0
- package/src/native/ffi/types.ts +474 -0
- package/src/native/ffi.ts +1775 -0
- package/src/native/index.ts +8 -207
- package/src/native/loader.ts +240 -0
- package/src/native/types/instances.ts +274 -0
- package/src/native/types.ts +401 -0
- 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 +10 -0
- 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 +87 -0
- package/src/rust-ffi/context.ts +193 -0
- package/src/rust-ffi/index.ts +51 -541
- package/src/rust-ffi/into.ts +21 -0
- package/src/rust-ffi/options.ts +113 -0
- package/src/rust-ffi/packed.ts +253 -0
- package/src/rust-ffi/proven.ts +28 -0
- package/src/rust-ffi/scalar/crypto.ts +346 -0
- package/src/rust-ffi/scalar/factories.ts +459 -0
- package/src/rust-ffi/scalar/hashing.ts +74 -0
- package/src/rust-ffi/scalar/http.ts +337 -0
- package/src/rust-ffi/scalar/index.ts +31 -0
- package/src/rust-ffi/scalar/interface.ts +325 -0
- package/src/rust-ffi/scalar/json-packed.ts +175 -0
- package/src/rust-ffi/scalar/json.ts +61 -0
- package/src/rust-ffi/scalar/payload.ts +241 -0
- package/src/rust-ffi/text.ts +121 -0
- package/src/selection.json +194 -0
- package/src/selection.ts +73 -0
- package/src/shared/adaptive.ts +67 -0
- package/src/shared/buffer-pool.ts +264 -0
- package/src/shared/bytes.ts +108 -9
- package/src/shared/codec.ts +78 -0
- package/src/shared/env.ts +20 -0
- package/src/shared/log.ts +75 -0
- package/src/shared/metrics.ts +247 -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/wire.ts +504 -0
- package/src/shared/proven.ts +289 -0
- package/src/shared/request-id.ts +67 -0
- package/src/shared/response.ts +104 -0
- package/src/shared/runtime.ts +29 -0
- package/src/shared/trace.ts +81 -0
- package/src/shared/uuid.ts +15 -0
- package/Cargo.toml +0 -73
- package/bench.ts +0 -3
- package/rust/batch.rs +0 -195
- package/rust/cookie_parser.rs +0 -70
- package/rust/cors.rs +0 -263
- package/rust/hashing.rs +0 -164
- package/rust/headers.rs +0 -234
- package/rust/hmac_sha256.rs +0 -113
- package/rust/http_parser.rs +0 -132
- package/rust/ingress.rs +0 -616
- package/rust/ingress_constants.rs +0 -89
- package/rust/ip_trust.rs +0 -185
- package/rust/json_ops.rs +0 -66
- package/rust/json_patch_ops.rs +0 -25
- package/rust/json_schema.rs +0 -151
- package/rust/json_ser.rs +0 -429
- package/rust/lib.rs +0 -48
- package/rust/method.rs +0 -141
- package/rust/mime_lookup.rs +0 -76
- package/rust/output.rs +0 -238
- package/rust/proxy.rs +0 -90
- package/rust/query_parser.rs +0 -141
- package/rust/random_token.rs +0 -52
- package/rust/rate_limit.rs +0 -203
- package/rust/terminal.rs +0 -95
- package/rust/unit_tests.rs +0 -843
- package/rust/url_codec.rs +0 -133
- package/rust/util.rs +0 -537
- package/rust/validation.rs +0 -107
- package/rust/websocket.rs +0 -23
- package/src/baseline/index.ts +0 -12
- package/src/baseline/tasks/cookie.ts +0 -22
- package/src/baseline/tasks/hashing.ts +0 -18
- package/src/baseline/tasks/hmac.ts +0 -38
- package/src/baseline/tasks/http.ts +0 -62
- package/src/baseline/tasks/json-patch.ts +0 -12
- package/src/baseline/tasks/json.ts +0 -50
- package/src/baseline/tasks/mime.ts +0 -5
- package/src/baseline/tasks/query.ts +0 -14
- package/src/baseline/tasks/token.ts +0 -15
- package/src/baseline/tasks/url.ts +0 -11
- package/src/baseline/tasks/validation.ts +0 -19
- package/src/baseline/tasks/websocket.ts +0 -14
- package/src/bench/assert.ts +0 -70
- package/src/bench/checks.ts +0 -227
- package/src/bench/checksum.ts +0 -27
- package/src/bench/comparisons.ts +0 -70
- package/src/bench/concurrent-worker.ts +0 -135
- package/src/bench/fixtures.ts +0 -233
- package/src/bench/index.ts +0 -2
- package/src/bench/measure.ts +0 -177
- package/src/bench/now.ts +0 -3
- package/src/bench/report.ts +0 -79
- package/src/bench/run.ts +0 -57
- package/src/bench/tasks/complex.ts +0 -232
- package/src/bench/tasks/concurrent.ts +0 -213
- package/src/bench/tasks/cookie.ts +0 -44
- package/src/bench/tasks/hashing.ts +0 -33
- package/src/bench/tasks/hmac.ts +0 -36
- package/src/bench/tasks/http.ts +0 -50
- package/src/bench/tasks/index.ts +0 -52
- package/src/bench/tasks/json-patch.ts +0 -21
- package/src/bench/tasks/json.ts +0 -33
- package/src/bench/tasks/mime.ts +0 -21
- package/src/bench/tasks/query.ts +0 -44
- package/src/bench/tasks/stress.ts +0 -142
- package/src/bench/tasks/token.ts +0 -20
- package/src/bench/tasks/url.ts +0 -34
- package/src/bench/tasks/validation.ts +0 -57
- package/src/bench/tasks/websocket.ts +0 -21
- package/src/bench/types.ts +0 -60
- package/src/data/json-rows.ts +0 -33
- package/src/ingress/packed-input.ts +0 -59
- package/src/shared/json.ts +0 -18
- package/src/shared/packed.ts +0 -316
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
# Benchmarks Guide
|
|
2
|
+
|
|
3
|
+
This document describes the benchmark framework, how to run benchmarks, and how to interpret results.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Overview
|
|
8
|
+
|
|
9
|
+
The benchmark system compares the Rust implementations against pure JavaScript/Bun baseline implementations. It measures throughput (ops/sec), latency (mean/percentiles), and memory allocation. Under Bun the `rust:*` side runs through the PRIMARY `bun:ffi` transport (NAPI is the fallback); the CPU bench warns — or hard-fails with `CASTRUM_BENCH_FFI=1` — if the ffi transport is not live.
|
|
10
|
+
|
|
11
|
+
### Benchmark Types
|
|
12
|
+
|
|
13
|
+
| Type | Description |
|
|
14
|
+
|------|-------------|
|
|
15
|
+
| **Unit benchmarks** | Individual function comparisons (hashing, validation, JSON, etc.) |
|
|
16
|
+
| **HTTP benchmarks** | Full HTTP server comparisons (Bun vs Elysia vs Ingress) |
|
|
17
|
+
| **Batch benchmarks** | Throughput for batch operations |
|
|
18
|
+
| **Stress benchmarks** | Performance under load |
|
|
19
|
+
| **Concurrency benchmarks** | Performance with multiple concurrent requests |
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## Running Benchmarks
|
|
24
|
+
|
|
25
|
+
### Unit Benchmarks
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
# Run all unit benchmarks
|
|
29
|
+
bun bench.ts
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
### Bun built-ins diagnostic set ("don't reinvent the wheel")
|
|
33
|
+
|
|
34
|
+
`bun run check` also races castrum ops against **Bun's native built-ins**
|
|
35
|
+
(`Bun.hash`, `Bun.password`, `Bun.CryptoHasher`, `Bun.gzipSync`,
|
|
36
|
+
`Bun.randomUUIDv7`) as a diagnostic set:
|
|
37
|
+
|
|
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.
|
|
40
|
+
- Sources: `src/baseline/tasks/bun-builtins.ts` (Bun-only baselines, guarded
|
|
41
|
+
with `typeof Bun` so the module still loads under Node),
|
|
42
|
+
`src/bench/tasks/bun-builtins.ts`, comparisons in `src/bench/comparisons.ts`.
|
|
43
|
+
- **Runtime honesty guard**: a Bun API must be verified to actually execute real
|
|
44
|
+
work before its result is trusted. The first run produced false "wins"
|
|
45
|
+
(brotli 156x, validators 3-5x) because Bun 1.4 has **no**
|
|
46
|
+
`Bun.brotliCompressSync` and **no** `Bun.validators` — those pairs were
|
|
47
|
+
removed, not reported.
|
|
48
|
+
- Results and the keep/delegate/add decisions live in
|
|
49
|
+
`docs/bun-builtins-decision-matrix.md`.
|
|
50
|
+
|
|
51
|
+
### HTTP Benchmarks
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
# Run all HTTP scenarios
|
|
55
|
+
bun run bench:http
|
|
56
|
+
|
|
57
|
+
# Run specific scenario
|
|
58
|
+
bun run bench:http:smoke # Light load, quick health check
|
|
59
|
+
bun run bench:http:stress # High concurrency, short duration
|
|
60
|
+
bun run bench:http:heavy # Heavy JSON payloads
|
|
61
|
+
bun run bench:http:crud # CRUD operations
|
|
62
|
+
bun run bench:http:spike # Burst traffic patterns
|
|
63
|
+
bun run bench:http:soak # Long duration (endurance)
|
|
64
|
+
bun run bench:http:storm # Very high concurrency burst
|
|
65
|
+
bun run bench:http:boundary # Edge case payloads
|
|
66
|
+
|
|
67
|
+
# Compare specific frameworks
|
|
68
|
+
bun run bench:http:elysia # Run only Elysia vs baseline
|
|
69
|
+
bun run bench:http:ingress # Run only Ingress vs baseline
|
|
70
|
+
bun run bench:http:bun-only # Run only raw Bun vs baseline
|
|
71
|
+
|
|
72
|
+
# Run all heavy scenarios
|
|
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):
|
|
80
|
+
# node bench/autocannon-stress.mjs # dynamic 03-stress mix
|
|
81
|
+
# AC_PATH=/api/users 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 node bench/autocannon-stress.mjs
|
|
84
|
+
# AC_RUNS=3 node bench/autocannon-stress.mjs # median-of-N (see §Single-core)
|
|
85
|
+
# — run-major interleaving: every server is measured once per round, so
|
|
86
|
+
# the median compares servers over the same load windows on a noisy host.
|
|
87
|
+
#
|
|
88
|
+
# Multi-core scaling (SO_REUSEPORT — the way to exceed the single-core ceiling):
|
|
89
|
+
# AC_INSTANCES=4 AC_PIPELINING=10 node bench/autocannon-stress.mjs
|
|
90
|
+
# (ingress/router only — they read INGRESS_REUSE_PORT=1)
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### Bun `fetch()` concurrency cap (read this before interpreting HTTP results)
|
|
94
|
+
|
|
95
|
+
Bun's `fetch()` limits the number of **simultaneous** requests to **256 by
|
|
96
|
+
default** (`BUN_CONFIG_MAX_HTTP_REQUESTS`, max 65,336). When a scenario's
|
|
97
|
+
`maxConcurrent` exceeds that cap, the excess requests queue **FIFO client-side**
|
|
98
|
+
and the run measures the *generator*, not the server — the classic signature is
|
|
99
|
+
a sub-ms `p50` with a multi-second tail and a flat RPS wall across every server.
|
|
100
|
+
|
|
101
|
+
- All `bench:http:*` scripts set `BUN_CONFIG_MAX_HTTP_REQUESTS=65536` for you.
|
|
102
|
+
- `bench/run-bench.ts` warns if a selected scenario exceeds the effective cap.
|
|
103
|
+
- For **max-throughput** server comparisons use **autocannon**
|
|
104
|
+
(`bench/autocannon-stress.mjs`, run with `node`): it has no such cap, uses a
|
|
105
|
+
real socket pool, scales across worker threads, and reports RPS + latency
|
|
106
|
+
percentiles natively. It replicates the `03-stress` weighted flow mix
|
|
107
|
+
(50% GET /api/users?q=…&page=…, 30% POST /api/users, 20% GET /health).
|
|
108
|
+
- **Two autocannon gotchas** (both fixed in the runner):
|
|
109
|
+
1. `requestGenerator` is CLI-ONLY — the library silently ignores it (every
|
|
110
|
+
request then goes to `GET /` → 404 → "0 2xx"). The runner uses the
|
|
111
|
+
library's `requests: [{ setupRequest }]` API instead.
|
|
112
|
+
2. `result.workers` is a count (`opts.workers`), not an array — the report no
|
|
113
|
+
longer treats it as a per-worker array.
|
|
114
|
+
|
|
115
|
+
### Single-core ceiling & multi-core scaling (measured 2026-08-16)
|
|
116
|
+
|
|
117
|
+
All four servers are **single-process `Bun.serve`** (one event loop), so they
|
|
118
|
+
pin at a per-core ceiling on this machine. The host is load-noisy, so use the
|
|
119
|
+
**median-of-N** runner (`AC_RUNS=N`, default 1) with **run-major interleaving** —
|
|
120
|
+
every server is visited once per round, so a single slow window affects all
|
|
121
|
+
servers' run-`i` equally and the median compares servers over the same load
|
|
122
|
+
windows (the old server-major order biased later servers into noisier windows).
|
|
123
|
+
> **Dated snapshot (2026-08-16 ingress-RPS pass).** The host is load-noisy;
|
|
124
|
+
> the newest persisted autocannon medians under `bench/results/autocannon/*/final-*.median.md`
|
|
125
|
+
> supersede these numbers run-to-run. Use the median-of-N runner below for fresh
|
|
126
|
+
> comparisons.
|
|
127
|
+
|
|
128
|
+
Current medians (`AC_DURATION=8 AC_RUNS=3`, 500 connections, single instance):
|
|
129
|
+
|
|
130
|
+
- `GET /api/users?q=…` (query + cookies + CORS + metadata envelope):
|
|
131
|
+
**bun 45.2k, elysia 41.7k, ingress 51.5k, router 49.4k RPS** — ingress ~**+14%**
|
|
132
|
+
vs bun, with a tighter tail (p50 9ms / p99 16ms vs bun p50 10ms / p99 28ms).
|
|
133
|
+
- `POST /api/users` (JSON body, fast-path schema validation):
|
|
134
|
+
**bun 40.8k, elysia 35.1k, ingress 45.4k, router 35.1k RPS** — ingress ~**+11%**
|
|
135
|
+
vs bun. The pre-optimization ingress POST gap (≈ −24% vs bun) is closed by
|
|
136
|
+
the changes in `CHANGELOG [Unreleased]` (JS hot-path elimination + single-pass
|
|
137
|
+
body validation).
|
|
138
|
+
- Per-request cost breakdown (`bun bench/ingress-cost.ts` / `ingress-cost-post.ts`,
|
|
139
|
+
min-of-5): GET `run` ~826ns (native 168ns + JS ~446ns packing + decode 60ns);
|
|
140
|
+
POST `run` ~1154ns (native ~740ns — was 808ns before the single-pass — + JS).
|
|
141
|
+
The POST body read is now **synchronous for buffered bodies**: `Bun.peek` on
|
|
142
|
+
`req.bytes()` skips the deadline race + watchdog for declared-length bodies
|
|
143
|
+
(was ~600ns of race machinery per POST).
|
|
144
|
+
- **To exceed the single-core ceiling**, run multiple server processes on the
|
|
145
|
+
same port via SO_REUSEPORT: `AC_INSTANCES=4` (with `AC_PIPELINING` so the
|
|
146
|
+
client can drive enough in-flight requests) scaled ingress from ~45k to
|
|
147
|
+
~69k RPS on this machine. Only ingress/router bind with `reusePort`
|
|
148
|
+
(`INGRESS_REUSE_PORT=1`); bun/elysia do not set it. For cleanest numbers run
|
|
149
|
+
autocannon on a separate host.
|
|
150
|
+
|
|
151
|
+
---
|
|
152
|
+
|
|
153
|
+
## HTTP Benchmark Scenarios
|
|
154
|
+
|
|
155
|
+
Each scenario targets a specific performance aspect:
|
|
156
|
+
|
|
157
|
+
### 01-smoke
|
|
158
|
+
- **Goal**: Quick health check (health, GET, POST mix)
|
|
159
|
+
- **Duration**: ~10 seconds
|
|
160
|
+
- **Concurrency**: up to 50
|
|
161
|
+
|
|
162
|
+
### 02-load
|
|
163
|
+
- **Goal**: Sustained mixed load (warm-up → sustain → cool-down)
|
|
164
|
+
- **Duration**: ~120 seconds
|
|
165
|
+
- **Concurrency**: up to 1,000
|
|
166
|
+
- **Traffic**: GET/POST mix with cookies, health
|
|
167
|
+
|
|
168
|
+
### 03-stress
|
|
169
|
+
- **Goal**: Ramp to maximum throughput
|
|
170
|
+
- **Duration**: ~100 seconds (5 × 20s ramp phases)
|
|
171
|
+
- **Concurrency**: up to 2,000 (higher on a single machine only deepens the
|
|
172
|
+
client-side fetch queue — the generator can't drive 10k usefully; see the
|
|
173
|
+
Bun fetch-cap note above. For true max-throughput use `bench:http:ac`.)
|
|
174
|
+
|
|
175
|
+
### 04-spike
|
|
176
|
+
- **Goal**: Burst resilience (baseline → spike → recovery)
|
|
177
|
+
- **Duration**: ~100 seconds
|
|
178
|
+
- **Concurrency**: up to 8,000 (spikes up to 5,000 req/s)
|
|
179
|
+
|
|
180
|
+
### 05-soak
|
|
181
|
+
- **Goal**: Endurance/stability
|
|
182
|
+
- **Duration**: ~600 seconds (10 minutes)
|
|
183
|
+
- **Concurrency**: up to 1,000
|
|
184
|
+
|
|
185
|
+
### 06-edge-cases
|
|
186
|
+
- **Goal**: Malformed / edge-case payloads
|
|
187
|
+
- **Duration**: ~30 seconds
|
|
188
|
+
- **Concurrency**: up to 200
|
|
189
|
+
- **Traffic**: invalid JSON, schema failures, 404/405/415/422, Unicode, oversize query
|
|
190
|
+
|
|
191
|
+
### 07-cors-preflight
|
|
192
|
+
- **Goal**: CORS preflight storm (allowed + disallowed origins)
|
|
193
|
+
- **Duration**: ~30 seconds
|
|
194
|
+
- **Concurrency**: up to 500
|
|
195
|
+
|
|
196
|
+
### 08-rate-limit
|
|
197
|
+
- **Goal**: Rate limiting (under limit → over limit → recovery)
|
|
198
|
+
- **Duration**: ~40 seconds
|
|
199
|
+
- **Concurrency**: up to 1,000
|
|
200
|
+
|
|
201
|
+
### 09-large-payload
|
|
202
|
+
- **Goal**: Large payload handling
|
|
203
|
+
- **Duration**: ~30 seconds
|
|
204
|
+
- **Concurrency**: up to 100
|
|
205
|
+
- **Payload**: large binary + large JSON arrays
|
|
206
|
+
|
|
207
|
+
### 10-mixed-realistic
|
|
208
|
+
- **Goal**: Realistic mixed traffic (origins, cookies, auth)
|
|
209
|
+
- **Duration**: ~60 seconds (ramp)
|
|
210
|
+
- **Concurrency**: up to 5,000
|
|
211
|
+
|
|
212
|
+
### 11-concurrent-burst
|
|
213
|
+
- **Goal**: High-concurrency bursts with pauses
|
|
214
|
+
- **Duration**: ~35 seconds
|
|
215
|
+
- **Concurrency**: up to 8,000
|
|
216
|
+
|
|
217
|
+
### 12-slowloris
|
|
218
|
+
- **Goal**: Slow clients
|
|
219
|
+
- **Duration**: ~60 seconds
|
|
220
|
+
- **Concurrency**: up to 500
|
|
221
|
+
|
|
222
|
+
### Heavy JSON scenarios (`13`–`20`)
|
|
223
|
+
|
|
224
|
+
`13-heavy-json-nested` … `20-validation-storm` stress schema validation with nested, array-heavy, wide, CRUD-mix, spike, soak, large-body, and storm payloads. Run them with `bun run bench:http:heavy`.
|
|
225
|
+
|
|
226
|
+
---
|
|
227
|
+
|
|
228
|
+
## Interpreting Results
|
|
229
|
+
|
|
230
|
+
### Report Format
|
|
231
|
+
|
|
232
|
+
Results are saved to `bench/results/<framework>/<scenario>.bench.md` and include:
|
|
233
|
+
|
|
234
|
+
```md
|
|
235
|
+
# Benchmark Report: bun / 01-smoke
|
|
236
|
+
|
|
237
|
+
| Metric | Value |
|
|
238
|
+
|--------|-------|
|
|
239
|
+
| Duration | 10.0s |
|
|
240
|
+
| Requests | 125,432 |
|
|
241
|
+
| Throughput | 12,543 req/s |
|
|
242
|
+
| Mean Latency | 0.79ms |
|
|
243
|
+
| p50 Latency | 0.72ms |
|
|
244
|
+
| p95 Latency | 1.13ms |
|
|
245
|
+
| p99 Latency | 2.45ms |
|
|
246
|
+
| Max Latency | 15.67ms |
|
|
247
|
+
| Errors | 0 |
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
### Comparison Reports
|
|
251
|
+
|
|
252
|
+
When comparing multiple frameworks, differences are expressed as:
|
|
253
|
+
|
|
254
|
+
- **Speedup**: `2.45x` (Rust is 2.45x faster than JS)
|
|
255
|
+
- **Slowdown**: `0.82x` (Rust is 0.82x the speed of JS, i.e., 18% slower)
|
|
256
|
+
- **% Improvement**: `+145%` throughput increase
|
|
257
|
+
|
|
258
|
+
### Key Metrics
|
|
259
|
+
|
|
260
|
+
| Metric | What It Measures |
|
|
261
|
+
|--------|-----------------|
|
|
262
|
+
| **Throughput** | Requests per second — higher is better |
|
|
263
|
+
| **Mean Latency** | Average response time — lower is better |
|
|
264
|
+
| **p50/p95/p99** | Percentile latencies — lower is better |
|
|
265
|
+
| **Max Latency** | Worst-case response time — should be bounded |
|
|
266
|
+
| **Error Rate** | Percentage of failed requests — should be 0% |
|
|
267
|
+
|
|
268
|
+
---
|
|
269
|
+
|
|
270
|
+
## Benchmark Architecture
|
|
271
|
+
|
|
272
|
+
```
|
|
273
|
+
bench.ts
|
|
274
|
+
└─ Loads benchmark tasks from src/bench/tasks/ (33 tasks)
|
|
275
|
+
├── hashing.ts Compare Rust fnv1a64 vs JS TextEncoder + hash
|
|
276
|
+
├── validation.ts Compare Rust validation vs validator library
|
|
277
|
+
├── json.ts Compare Rust sonic-rs vs JSON.parse
|
|
278
|
+
├── http.ts Compare Rust httparse vs manual
|
|
279
|
+
├── complex.ts Combined operations
|
|
280
|
+
├── stress.ts Concurrent load tests
|
|
281
|
+
└── ... (aead, accept, compress, cookie-sign, csrf, encoding, etag,
|
|
282
|
+
form, json-schema, jwt, media-type, multipart, password,
|
|
283
|
+
streaming, template, url-join, ... — full list in docs/REPO_MAP.md)
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
Each task runs:
|
|
287
|
+
1. **Warmup**: 100ms to JIT-compile JavaScript
|
|
288
|
+
2. **Measurement**: Fixed duration (default 2000ms) in a tight loop
|
|
289
|
+
3. **Operations counted**: Number of function calls completed
|
|
290
|
+
4. **Result**: Ops/sec calculated from wall-clock time
|
|
291
|
+
|
|
292
|
+
### Benchmark controls
|
|
293
|
+
|
|
294
|
+
| Env / flag | Effect |
|
|
295
|
+
|------------|--------|
|
|
296
|
+
| `CASTRUM_BENCH_BATCH_SIZE` | Batch size for sub-µs operations (default `64`). Batching amortizes the timer/measurement overhead for very fast ops. |
|
|
297
|
+
| `HTTP_NO_SHAPE=1` | The HTTP load generator (`bench/load.ts`) skips response-shape `JSON.parse` for pure-throughput runs. |
|
|
298
|
+
|
|
299
|
+
---
|
|
300
|
+
|
|
301
|
+
## HTTP Server Architecture
|
|
302
|
+
|
|
303
|
+
```
|
|
304
|
+
bench/servers/
|
|
305
|
+
├── bun-server.ts # Raw Bun.serve with manual routing
|
|
306
|
+
├── elysia-server.ts # Elysia framework
|
|
307
|
+
├── ingress-server.ts # Ingress (this project) optimized handler
|
|
308
|
+
├── router-server.ts # Ingress per-route compiled-router variant (createIngressRouter)
|
|
309
|
+
└── shared.ts # Shared route handlers, PORTS, env helpers
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
Each server implements the same routes for fair comparison:
|
|
313
|
+
|
|
314
|
+
- `GET /` — Plain text hello
|
|
315
|
+
- `GET /json` — JSON response
|
|
316
|
+
- `POST /json` — Echo JSON body
|
|
317
|
+
- `GET /id/:id` — Path parameter
|
|
318
|
+
- `POST /users` — User creation
|
|
319
|
+
|
|
320
|
+
---
|
|
321
|
+
|
|
322
|
+
## Environment
|
|
323
|
+
|
|
324
|
+
All benchmarks should be run on a dedicated machine for reproducible results:
|
|
325
|
+
|
|
326
|
+
- **CPU**: Modern x86_64 (Intel/AMD) or Apple Silicon
|
|
327
|
+
- **Memory**: 8GB+ RAM
|
|
328
|
+
- **OS**: Linux (recommended) or macOS
|
|
329
|
+
- **Bun**: Latest stable
|
|
330
|
+
- **Rust**: Latest stable (via rustup)
|
|
331
|
+
|
|
332
|
+
### Turbo Mode (Linux)
|
|
333
|
+
|
|
334
|
+
For consistent results on Linux, disable CPU frequency scaling:
|
|
335
|
+
|
|
336
|
+
```bash
|
|
337
|
+
sudo cpupower frequency-set --governor performance
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
Run benchmarks, then restore:
|
|
341
|
+
|
|
342
|
+
```bash
|
|
343
|
+
sudo cpupower frequency-set --governor powersave
|
|
@@ -0,0 +1,240 @@
|
|
|
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.rs`, **79 `castrum_*` symbols** — 71 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 · 75 C-ABI symbols · ~10–20 ns"| RUST["Rust cdylib (rust/ffi.rs)"]
|
|
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
|
+
|
|
139
|
+
| Server | Achieved RPS | Total requests | Unexpected errors | Shape failures |
|
|
140
|
+
|--------|-------------|----------------|-------------------|----------------|
|
|
141
|
+
| raw `Bun.serve` | 862.5 | 92,174 | 0 | 0 |
|
|
142
|
+
| **castrum ingress** | **904.2** | **94,060** | **0** | **0** |
|
|
143
|
+
|
|
144
|
+
Under extreme concurrency both saturate and show long tail latencies (p99 ~18 s
|
|
145
|
+
on this machine) — that's the load generator over-driving the box, not a bug.
|
|
146
|
+
The meaningful result is that adding the full pipeline (CORS + rate limit +
|
|
147
|
+
body guard + schema + security headers + request IDs) costs **no correctness
|
|
148
|
+
and no measurable throughput** versus a hand-rolled raw server.
|
|
149
|
+
|
|
150
|
+
### 3.4 The bug that almost killed the server: C-ABI panics
|
|
151
|
+
|
|
152
|
+
The most instructive moment: under `11-concurrent-burst`, the ingress server
|
|
153
|
+
**died at ~2.5 s and stayed down** (91% network errors). Root cause: the `bun:ffi`
|
|
154
|
+
`extern "C"` exports had no panic guard. A panic in a napi call becomes a JS
|
|
155
|
+
exception, but a panic unwinding through raw `extern "C"` is undefined behaviour
|
|
156
|
+
that takes the whole process down.
|
|
157
|
+
|
|
158
|
+
The fix (in `rust/ffi.rs`): every fallible / allocating C-ABI export now routes
|
|
159
|
+
its core through `panic_guard` (`catch_unwind`) and reports a sentinel instead
|
|
160
|
+
of panicking. After the fix, the same scenario completes cleanly:
|
|
161
|
+
|
|
162
|
+
| Scenario (post-fix) | Requests | Network errors | Unexpected errors |
|
|
163
|
+
|---------------------|----------|----------------|-------------------|
|
|
164
|
+
| `11-concurrent-burst` | 30,211 | 0 | 0 |
|
|
165
|
+
|
|
166
|
+
**Lesson:** if you expose a raw C ABI to a host runtime, you own the unwind
|
|
167
|
+
boundary. Never assume a panic can't escape — contain it explicitly, and make
|
|
168
|
+
your bind-time self-test exercise the fallible paths.
|
|
169
|
+
|
|
170
|
+
### 3.5 Correctness as a first-class gate
|
|
171
|
+
|
|
172
|
+
The HTTP smoke scenario (`01-smoke`) is a **wire-format guard**, not a
|
|
173
|
+
throughput test: it verifies `ok === true` + `requestId` on success and
|
|
174
|
+
`error.code` / `error.message` on failure for every response. Across the
|
|
175
|
+
scenarios, `shape_failures` and `unexpected_statuses` are 0 — the two wire
|
|
176
|
+
formats are validated every CI run, so a change to the response shape breaks
|
|
177
|
+
the benchmark, not prod.
|
|
178
|
+
|
|
179
|
+
---
|
|
180
|
+
|
|
181
|
+
## 4. Engineering decisions & lessons
|
|
182
|
+
|
|
183
|
+
1. **Two transports, one binary.** Build with napi-rs (portability), export
|
|
184
|
+
`extern "C"` (speed on Bun), and let a **bind-time self-test** pick the safe
|
|
185
|
+
path automatically. Never let a transport failure be silent.
|
|
186
|
+
2. **Benchmark against the built-in, not just the baseline.** Bun's `Bun.hash.*`,
|
|
187
|
+
`Bun.gzipSync`, `Bun.CryptoHasher`, and `Bun.randomUUIDv7` beat an FFI
|
|
188
|
+
crossing on several ops. The rule that emerged: **keep Rust where it wins or
|
|
189
|
+
where Bun has no synchronous equivalent; delegate where Bun wins.** Don't
|
|
190
|
+
reinvent the wheel — `docs/adr/0003-bun-builtins-delegation.md`.
|
|
191
|
+
3. **"Native" is not a synonym for "fast".** The `builtins` registry delegates
|
|
192
|
+
where Bun wins. Let data, not enthusiasm, decide.
|
|
193
|
+
4. **A wire format is a contract.** Two deliberately-different formats are
|
|
194
|
+
pinned by tests and the load generator. If you change the shape, you change
|
|
195
|
+
the benchmark.
|
|
196
|
+
5. **Contain the C ABI.** A panic across `extern "C"` is a crash. Route every
|
|
197
|
+
fallible export through a guard and cover it in the self-test.
|
|
198
|
+
6. **Zero-copy is a discipline.** Packed buffers, pooled outputs, lazy decode —
|
|
199
|
+
every allocation you remove from the hot path is tail latency you never pay.
|
|
200
|
+
7. **Be honest about scope.** The rate limiter is **per-process, not
|
|
201
|
+
distributed**; proxy trust is **off by default** (spoof-proof); the
|
|
202
|
+
decompression bomb cap is **64 MiB**. Documenting the boundaries is as
|
|
203
|
+
important as the speed.
|
|
204
|
+
|
|
205
|
+
---
|
|
206
|
+
|
|
207
|
+
## 5. Reproduce it yourself
|
|
208
|
+
|
|
209
|
+
```bash
|
|
210
|
+
bun install
|
|
211
|
+
bun run build # release addon (baseline CPU — what ships)
|
|
212
|
+
|
|
213
|
+
bun run check # CPU benchmark → bench/results/cpu/latest.json
|
|
214
|
+
bun run bench:startup # import + first-call timing
|
|
215
|
+
bun run bench:http:smoke # HTTP wire-format guard
|
|
216
|
+
bun run bench:http # all HTTP scenarios (bun / elysia / ingress)
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
See [`BENCHMARKS.md`](./BENCHMARKS.md) for scenario descriptions and how to read
|
|
220
|
+
the reports.
|
|
221
|
+
|
|
222
|
+
---
|
|
223
|
+
|
|
224
|
+
## 6. When this pattern applies (and when it doesn't)
|
|
225
|
+
|
|
226
|
+
The hybrid TS + Rust + FFI pattern paid off here because the workload was
|
|
227
|
+
**CPU-bound, synchronous, and high-volume**. Reach for it when:
|
|
228
|
+
|
|
229
|
+
- Request handling spends measurable time in validation/parsing/hashing.
|
|
230
|
+
- You want a pre-baked HTTP pipeline (CORS, rate limit, schema, security
|
|
231
|
+
headers) without a heavy middleware graph.
|
|
232
|
+
- You must support both Bun and Node from one codebase.
|
|
233
|
+
|
|
234
|
+
Don't reach for it when:
|
|
235
|
+
|
|
236
|
+
- The bottleneck is I/O or the database, not CPU — Rust won't help.
|
|
237
|
+
- You only need a handful of ops that Bun's built-ins already win (use
|
|
238
|
+
`Bun.hash.crc32`, `Bun.gzipSync`, `Bun.randomUUIDv7` directly).
|
|
239
|
+
- You can't ship/ build a native `.node` in your deployment (no Rust toolchain
|
|
240
|
+
in CI, or a locked-down runtime that forbids native addons).
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# Environment Variables Reference
|
|
2
|
+
|
|
3
|
+
This document lists every environment variable read by the package and its
|
|
4
|
+
example servers. All values are read once at module load; invalid values are
|
|
5
|
+
warned about and replaced with the documented default.
|
|
6
|
+
|
|
7
|
+
## Ingress server (`bench/servers/ingress-server.ts`)
|
|
8
|
+
|
|
9
|
+
| Variable | Type | Default | Description |
|
|
10
|
+
|----------|------|---------|-------------|
|
|
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
|
+
| `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). |
|
|
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`). |
|
|
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. |
|
|
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. |
|
|
18
|
+
| `INGRESS_REUSE_PORT` | bool | `false` | Enable `SO_REUSEPORT` for multi-process deployments (spawn N processes, each with a distinct `INGRESS_WORKER_ID`). |
|
|
19
|
+
| `INGRESS_LOG_REQUESTS` | bool | `false` | Write one JSON line per request to **stderr** (`{ts, kind, requestId, method, path, status, errorCode?, rateLimited?, ...}`). |
|
|
20
|
+
| `INGRESS_METRICS` | bool | `false` | Write a periodic aggregate summary to **stderr** every 10s (`{ts, kind, total, rps, status, rateLimited, internalErrors}`). |
|
|
21
|
+
| `INGRESS_HOST` | string | `0.0.0.0` | Bind address. |
|
|
22
|
+
| `INGRESS_IDLE_TIMEOUT` | int | `30` | Idle keep-alive timeout in seconds (min `1`). |
|
|
23
|
+
| `INGRESS_WORKER_ID` | int | `0` | Worker identifier (0–65535), mixed into request-ID generation to reduce collision risk across processes. |
|
|
24
|
+
|
|
25
|
+
Boolean flags accept `1`/`true`/`yes`/`on` for true and `0`/`false`/`no`/`off`
|
|
26
|
+
for false; anything else is warned about and falls back to the default.
|
|
27
|
+
|
|
28
|
+
## Rust FFI thread pool
|
|
29
|
+
|
|
30
|
+
| Variable | Type | Default | Description |
|
|
31
|
+
|----------|------|---------|-------------|
|
|
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. |
|
|
35
|
+
|
|
36
|
+
## Native transport selection (`src/native/ffi.ts`)
|
|
37
|
+
|
|
38
|
+
`bun:ffi` is the PRIMARY transport under Bun; NAPI is the fallback (Node,
|
|
39
|
+
`CASTRUM_FFI_MODE=napi`, or a failed bind-time self-test). `rust.transport()`
|
|
40
|
+
reports the resolved transport (`"ffi"` / `"napi"`); `rust.ffiActive()` reports
|
|
41
|
+
whether the ffi transport is live.
|
|
42
|
+
|
|
43
|
+
| Variable | Type | Default | Description |
|
|
44
|
+
|----------|------|---------|-------------|
|
|
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). |
|
|
46
|
+
|
|
47
|
+
## Native addon loader (`src/native/loader.ts`)
|
|
48
|
+
|
|
49
|
+
| Variable | Type | Default | Description |
|
|
50
|
+
|----------|------|---------|-------------|
|
|
51
|
+
| `CASTRUM_DEBUG` | (presence) | — | Log the resolved addon path and exported keys at load time. |
|
|
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. |
|
|
53
|
+
|
|
54
|
+
## Library logging (`src/shared/log.ts`)
|
|
55
|
+
|
|
56
|
+
| Variable | Type | Default | Description |
|
|
57
|
+
|----------|------|---------|-------------|
|
|
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, ...}`). |
|
|
59
|
+
|
|
60
|
+
## Security guidance
|
|
61
|
+
|
|
62
|
+
- **Proxy trust**: `trustProxy: true` is deprecated — it trusts EVERY hop of
|
|
63
|
+
`X-Forwarded-For`/`X-Real-IP`, so clients can forge their IP and bypass IP-based
|
|
64
|
+
rate limiting. Prefer `trustedProxies: { enabled: true, networks: [...] }` and only
|
|
65
|
+
enable proxy trust behind a trusted edge that strips these headers.
|
|
66
|
+
- **Rate limiting is per-process, not distributed**: with N worker processes
|
|
67
|
+
(`reusePort`), an IP's effective limit is multiplied by N. The shared-limiter config
|
|
68
|
+
registry is bounded (LRU, max 16 distinct configs) to prevent unbounded memory growth.
|
|
69
|
+
- **Request bodies**: `createIngressServer` applies a server-level `maxRequestBodySize`
|
|
70
|
+
default of 16 MiB, and JSON-write routes stream-read with `maxBodyBytes` enforced
|
|
71
|
+
while reading (an oversized body is rejected as soon as the limit is crossed, never
|
|
72
|
+
fully buffered). Route/async handlers apply a non-zero body-read deadline by default
|
|
73
|
+
(`bodyTimeoutMs`, default **30 s** — set `bodyTimeoutMs: 0` to disable) that guards
|
|
74
|
+
against slowloris/trickling bodies.
|
|
75
|
+
- **Secrets (JWT/AEAD)**: keys and secrets are caller-owned buffers; treat them as
|
|
76
|
+
sensitive (never log them, rotate regularly). The AEAD **batch** APIs derive a unique
|
|
77
|
+
nonce per item, but callers MUST use a fresh base nonce for every batch call — never
|
|
78
|
+
reuse a base nonce with the same key.
|
|
79
|
+
- **Multipart**: `multipart_parse` accepts an optional `limits` object
|
|
80
|
+
(`max_parts`, `max_field_count`, `max_part_bytes`, `max_total_bytes`) with generous
|
|
81
|
+
defaults to bound worst-case parse cost.
|