castrum 0.7.0 → 0.9.0

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 (176) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +322 -59
  3. package/castrum.linux-x64-gnu.node +0 -0
  4. package/dist/index.d.ts +3161 -0
  5. package/dist/index.js +7306 -0
  6. package/docs/ARCHITECTURE.md +415 -0
  7. package/docs/BENCHMARKS.md +307 -0
  8. package/docs/ENVIRONMENT.md +79 -0
  9. package/docs/GETTING_STARTED.md +209 -0
  10. package/docs/INGRESS.md +745 -0
  11. package/docs/REPO_MAP.md +259 -0
  12. package/docs/adr/0001-two-wire-formats.md +39 -0
  13. package/docs/adr/0002-zero-dom-fast-schema.md +38 -0
  14. package/docs/adr/0003-bun-builtins-delegation.md +42 -0
  15. package/docs/bun-builtins-decision-matrix.md +108 -0
  16. package/index.ts +51 -8
  17. package/package.json +73 -20
  18. package/src/data/json-rows.ts +13 -13
  19. package/src/ingress/body.ts +149 -0
  20. package/src/ingress/constants.ts +220 -48
  21. package/src/ingress/context.ts +136 -0
  22. package/src/ingress/decode/baked-result.ts +121 -0
  23. package/src/ingress/decode/fast-result.ts +160 -0
  24. package/src/ingress/decode/packed-sections.ts +56 -0
  25. package/src/ingress/decode/result-base.ts +172 -0
  26. package/src/ingress/errors.ts +72 -0
  27. package/src/ingress/fast.ts +152 -1021
  28. package/src/ingress/handlers.ts +563 -1001
  29. package/src/ingress/headers/baked-templates.ts +102 -0
  30. package/src/ingress/headers/cors.ts +44 -0
  31. package/src/ingress/headers/fast-templates.ts +238 -0
  32. package/src/ingress/headers/hsts.ts +83 -0
  33. package/src/ingress/health.ts +67 -0
  34. package/src/ingress/index.ts +130 -405
  35. package/src/ingress/metrics.ts +132 -0
  36. package/src/ingress/options.ts +197 -0
  37. package/src/ingress/packing/gather-raw-headers.ts +75 -0
  38. package/src/ingress/packing/header-packing.ts +75 -0
  39. package/src/ingress/packing/input-packer.ts +150 -0
  40. package/src/ingress/packing/scratch.ts +50 -0
  41. package/src/ingress/packing/select-headers.ts +96 -0
  42. package/src/ingress/response/error-bodies.ts +75 -0
  43. package/src/ingress/response/terminal.ts +59 -0
  44. package/src/ingress/routes/common.ts +38 -0
  45. package/src/ingress/routes/delete.ts +14 -0
  46. package/src/ingress/routes/echo.ts +107 -0
  47. package/src/ingress/routes/fallback.ts +23 -0
  48. package/src/ingress/routes/head.ts +37 -0
  49. package/src/ingress/routes/index.ts +10 -0
  50. package/src/ingress/routes/json-write.ts +153 -0
  51. package/src/ingress/routes/options.ts +36 -0
  52. package/src/ingress/routes/read.ts +45 -0
  53. package/src/ingress/server-node.ts +376 -0
  54. package/src/ingress/server.ts +443 -0
  55. package/src/ingress/shared.ts +132 -0
  56. package/src/ingress/status.ts +97 -0
  57. package/src/ingress/types.ts +157 -0
  58. package/src/integration/batch.ts +58 -0
  59. package/src/integration/index.ts +29 -0
  60. package/src/integration/pipeline.ts +217 -0
  61. package/src/integration/streaming.ts +79 -0
  62. package/src/integration/websocket.ts +92 -0
  63. package/src/loader/batch.ts +133 -0
  64. package/src/loader/cost.ts +104 -0
  65. package/src/loader/index.ts +930 -0
  66. package/src/loader/ops.ts +657 -0
  67. package/src/native/ffi.ts +1541 -0
  68. package/src/native/index.ts +8 -207
  69. package/src/native/loader.ts +194 -0
  70. package/src/native/types.ts +598 -0
  71. package/src/rust-ffi/addon.ts +10 -0
  72. package/src/rust-ffi/batch.ts +431 -0
  73. package/src/rust-ffi/client.ts +88 -0
  74. package/src/rust-ffi/context.ts +164 -0
  75. package/src/rust-ffi/index.ts +40 -541
  76. package/src/rust-ffi/options.ts +113 -0
  77. package/src/rust-ffi/packed.ts +253 -0
  78. package/src/rust-ffi/proven.ts +42 -0
  79. package/src/rust-ffi/scalar/crypto.ts +265 -0
  80. package/src/rust-ffi/scalar/factories.ts +233 -0
  81. package/src/rust-ffi/scalar/hashing.ts +72 -0
  82. package/src/rust-ffi/scalar/http.ts +206 -0
  83. package/src/rust-ffi/scalar/index.ts +31 -0
  84. package/src/rust-ffi/scalar/interface.ts +450 -0
  85. package/src/rust-ffi/scalar/json.ts +45 -0
  86. package/src/rust-ffi/scalar/payload.ts +124 -0
  87. package/src/rust-ffi/text.ts +95 -0
  88. package/src/selection.json +194 -0
  89. package/src/selection.ts +77 -0
  90. package/src/shared/adaptive.ts +68 -0
  91. package/src/shared/bench-classify.ts +209 -0
  92. package/src/shared/buffer-pool.ts +209 -0
  93. package/src/shared/bytes.ts +35 -5
  94. package/src/shared/env.ts +20 -0
  95. package/src/shared/log.ts +74 -0
  96. package/src/shared/metrics.ts +247 -0
  97. package/src/shared/packed.ts +350 -154
  98. package/src/shared/proven.ts +596 -0
  99. package/src/shared/request-id.ts +67 -0
  100. package/src/shared/response.ts +104 -0
  101. package/src/shared/runtime.ts +34 -0
  102. package/src/shared/trace.ts +81 -0
  103. package/src/shared/uuid.ts +31 -0
  104. package/Cargo.toml +0 -73
  105. package/bench.ts +0 -3
  106. package/rust/batch.rs +0 -195
  107. package/rust/cookie_parser.rs +0 -70
  108. package/rust/cors.rs +0 -263
  109. package/rust/hashing.rs +0 -164
  110. package/rust/headers.rs +0 -234
  111. package/rust/hmac_sha256.rs +0 -113
  112. package/rust/http_parser.rs +0 -132
  113. package/rust/ingress.rs +0 -616
  114. package/rust/ingress_constants.rs +0 -89
  115. package/rust/ip_trust.rs +0 -185
  116. package/rust/json_ops.rs +0 -66
  117. package/rust/json_patch_ops.rs +0 -25
  118. package/rust/json_schema.rs +0 -151
  119. package/rust/json_ser.rs +0 -429
  120. package/rust/lib.rs +0 -48
  121. package/rust/method.rs +0 -141
  122. package/rust/mime_lookup.rs +0 -76
  123. package/rust/output.rs +0 -238
  124. package/rust/proxy.rs +0 -90
  125. package/rust/query_parser.rs +0 -141
  126. package/rust/random_token.rs +0 -52
  127. package/rust/rate_limit.rs +0 -203
  128. package/rust/terminal.rs +0 -95
  129. package/rust/unit_tests.rs +0 -843
  130. package/rust/url_codec.rs +0 -133
  131. package/rust/util.rs +0 -537
  132. package/rust/validation.rs +0 -107
  133. package/rust/websocket.rs +0 -23
  134. package/src/baseline/index.ts +0 -12
  135. package/src/baseline/tasks/cookie.ts +0 -22
  136. package/src/baseline/tasks/hashing.ts +0 -18
  137. package/src/baseline/tasks/hmac.ts +0 -38
  138. package/src/baseline/tasks/http.ts +0 -62
  139. package/src/baseline/tasks/json-patch.ts +0 -12
  140. package/src/baseline/tasks/json.ts +0 -50
  141. package/src/baseline/tasks/mime.ts +0 -5
  142. package/src/baseline/tasks/query.ts +0 -14
  143. package/src/baseline/tasks/token.ts +0 -15
  144. package/src/baseline/tasks/url.ts +0 -11
  145. package/src/baseline/tasks/validation.ts +0 -19
  146. package/src/baseline/tasks/websocket.ts +0 -14
  147. package/src/bench/assert.ts +0 -70
  148. package/src/bench/checks.ts +0 -227
  149. package/src/bench/checksum.ts +0 -27
  150. package/src/bench/comparisons.ts +0 -70
  151. package/src/bench/concurrent-worker.ts +0 -135
  152. package/src/bench/fixtures.ts +0 -233
  153. package/src/bench/index.ts +0 -2
  154. package/src/bench/measure.ts +0 -177
  155. package/src/bench/now.ts +0 -3
  156. package/src/bench/report.ts +0 -79
  157. package/src/bench/run.ts +0 -57
  158. package/src/bench/tasks/complex.ts +0 -232
  159. package/src/bench/tasks/concurrent.ts +0 -213
  160. package/src/bench/tasks/cookie.ts +0 -44
  161. package/src/bench/tasks/hashing.ts +0 -33
  162. package/src/bench/tasks/hmac.ts +0 -36
  163. package/src/bench/tasks/http.ts +0 -50
  164. package/src/bench/tasks/index.ts +0 -52
  165. package/src/bench/tasks/json-patch.ts +0 -21
  166. package/src/bench/tasks/json.ts +0 -33
  167. package/src/bench/tasks/mime.ts +0 -21
  168. package/src/bench/tasks/query.ts +0 -44
  169. package/src/bench/tasks/stress.ts +0 -142
  170. package/src/bench/tasks/token.ts +0 -20
  171. package/src/bench/tasks/url.ts +0 -34
  172. package/src/bench/tasks/validation.ts +0 -57
  173. package/src/bench/tasks/websocket.ts +0 -21
  174. package/src/bench/types.ts +0 -60
  175. package/src/ingress/packed-input.ts +0 -59
  176. package/src/shared/json.ts +0 -18
@@ -0,0 +1,745 @@
1
+ # Ingress Pipeline Documentation
2
+
3
+ ## Overview
4
+
5
+ The Ingress pipeline is the core HTTP request processing system in castrum. It handles incoming requests through a series of processing stages, writing decisions to a binary output buffer that is then interpreted by the TypeScript caller.
6
+
7
+ The 8-stage native pipeline (shared by both consumption paths — see below):
8
+
9
+ ```mermaid
10
+ flowchart TD
11
+ A["Request (packed frame:<br/>method kind, url, ip, rid, headers)"] --> S1
12
+ S1["1. Parse the packed input frame"] --> S2
13
+ S2["2. Trust, client IP, HTTPS"] --> S3
14
+ S3["3. CORS (may terminate on a preflight request)"] --> S4
15
+ S4["4. Rate limiting (may terminate with 429)"] --> S5
16
+ S5["5. Body size guard (may terminate with 413)"] --> S6
17
+ S6["6. JSON body validation &amp; schema (400 / 422)"] --> S7
18
+ S7["7. Serialize cookies / query / metadata"] --> S8
19
+ S8["8. Write the output header"]
20
+ S3 -.->|preflight 204 / 403| T["Terminal response"]
21
+ S4 -.->|429| T
22
+ S5 -.->|413| T
23
+ S6 -.->|400 / 422| T
24
+ ```
25
+
26
+ Both the fast path (`createIngressFast`) and the pre-baked path
27
+ (`createIngressHandler`) drive the **same** native core (`handle_packed`); they
28
+ differ only in how the request frame is packed in JS and how the output buffer
29
+ is decoded on the way out.
30
+
31
+ ---
32
+
33
+ ## Quick Start
34
+
35
+ ```ts
36
+ import { createIngress, createIngressFast } from "castrum";
37
+
38
+ // Fast synchronous handler (reuses internal buffers)
39
+ const fast = createIngressFast({
40
+ parseCookies: true,
41
+ parseQuery: true,
42
+ cors: { allowOrigin: ["https://example.com"] },
43
+ rateLimit: { limit: 100, windowMs: 60_000 },
44
+ });
45
+
46
+ // Inside your Bun server:
47
+ Bun.serve({
48
+ fetch(req) {
49
+ return fast.run(req, "192.168.1.1", null, "", (result) => {
50
+ if (result.ok) {
51
+ return new Response(JSON.stringify({ status: "ok" }), {
52
+ headers: { "content-type": "application/json" },
53
+ });
54
+ }
55
+ return new Response("Error", { status: result.status });
56
+ });
57
+ },
58
+ });
59
+
60
+ // Or use the async API (handles body reading automatically):
61
+ const handler = createIngress({
62
+ requireJsonBody: true,
63
+ schema: schemaBytes, // optional JSON Schema
64
+ enableRequestIds: true,
65
+ });
66
+
67
+ Bun.serve({
68
+ async fetch(req) {
69
+ const ctx = await handler(req);
70
+ if (ctx.response) return ctx.response; // terminal response (error)
71
+ return new Response("OK", { status: 200 });
72
+ },
73
+ });
74
+ ```
75
+
76
+ ---
77
+
78
+ ## Pre-Baked Handlers (recommended for servers)
79
+
80
+ The fastest way to consume ingress in **any** system. `src/ingress/handlers.ts`
81
+ wraps the optimized native pipeline (`Ingress.handleRequestPacked`) and
82
+ provides ready-made route handlers, response builders, and a `Bun.serve`
83
+ builder — no need to hand-build responses, header templates, or error bodies.
84
+
85
+ All of these are exported from the package root and are **re-exported from the
86
+ benchmark server** (`bench/servers/ingress-server.ts`), which itself is built
87
+ entirely from them:
88
+
89
+ ```ts
90
+ import { createIngressHandler, createIngressServer } from "castrum";
91
+
92
+ const ingress = createIngressHandler({
93
+ parseCookies: true,
94
+ parseQuery: true,
95
+ cors: { allowOrigin: ["https://app.example.com"] },
96
+ rateLimit: { limit: 1000, windowMs: 60_000 },
97
+ });
98
+
99
+ createIngressServer({
100
+ port: 3000,
101
+ routes: {
102
+ "/health": { read: ingress }, // GET + HEAD
103
+ "/api/users": { read: ingress, write: ingress }, // + POST/PUT/PATCH (+OPTIONS)
104
+ "/api/echo": { echo: ingress }, // POST echo
105
+ },
106
+ });
107
+ ```
108
+
109
+ > **Wire format** (a contract — `bench/load.ts` depends on it):
110
+ > - Success (with `emitMetadataJson: true` — the bench enables it): the body is
111
+ > Rust-generated `{"ok":true,"requestId":"...",...}`. With the DEFAULT
112
+ > `emitMetadataJson: false` the success body is **empty** (lean reads) — serve
113
+ > `result.bodyJson()` / `cookiesJson()` / `queryJson()` instead.
114
+ > - Errors: `{"ok":false,"error":{"code":"...","message":"..."}}` (rate limits
115
+ > also include `"retry_after_ms"`).
116
+ > - Rate-limit headers use `ratelimit-limit/-remaining/-reset` (not
117
+ > `x-ratelimit-*`). This differs from `createIngressFast`/`createIngress`,
118
+ > which emit `{"error":{code,status,message,requestId}}`. Do not mix the two.
119
+
120
+ ### `createIngressHandler(options, runtime?)`
121
+
122
+ Creates an optimized ingress handler (packs the request frame in JS via
123
+ `IngressInputPacker` + `gatherRawHeadersPacked` and drives the SAME native
124
+ core as `createIngressFast`, `handleRequestPacked`). `options` are the same
125
+ native options as `createIngressFast` (`trustProxy`, `https`,
126
+ `maxBodyBytes`, `enableBodySizeGuard`, `emitMetadataJson`, `cors`, `rateLimit`,
127
+ `parseCookies`, `parseQuery`, `schema`, `trustedProxies`, …).
128
+
129
+ `runtime` configures behavior that is not sent to Rust:
130
+
131
+ | Runtime option | Type | Default | Description |
132
+ |----------------|------|---------|-------------|
133
+ | `emitRequestIdHeader` | `boolean` | `false` | Echo `x-request-id` on responses |
134
+ | `enableSecurityHeaders` | `boolean` | `true` | Emit the configured security headers |
135
+ | `securityHeaders` | `[string,string][]` | — | Ordered security header pairs (names lowercased) |
136
+ | `outputBufferSize` | `number` | `131072` | Native output buffer size |
137
+ | `onRequest` | `(req, requestId, ip) => void` | — | Hook invoked before a request is processed |
138
+ | `onResponse` | `(req, result, status, requestId) => void` | — | Hook invoked after a `Response` is produced (metrics/logging) |
139
+ | `onError` | `(req, requestId, error) => void` | — | Hook invoked when the native pipeline fails (silent 500s are otherwise invisible) |
140
+ | `structuredLog` | `boolean` | `false` | Emit one JSON line per request/error (gated by `CASTRUM_LOG_LEVEL`) |
141
+
142
+ Returns an `OptimizedIngressHandler`:
143
+
144
+ ```ts
145
+ interface OptimizedIngressHandler {
146
+ // Run one request through the pipeline. The callback MUST be synchronous;
147
+ // the result is invalidated after it returns.
148
+ run<T>(req, ip, body, fn: (result: BakedIngressResult, ctx: BakedContext) => T): T;
149
+
150
+ // Pre-baked response builders, bound to this handler's config:
151
+ responseHeaders(variant, requestIdHeader, origin, rateRemaining?, rateResetSecs?, retryAfterSecs?): [string,string][];
152
+ terminalHeaders(variant, ctx, result): [string,string][];
153
+ terminalResponse(req, result, ctx): Response | null; // null when not terminal
154
+ errorResponse(req, result, status, code, message, ctx): Response;
155
+ internalErrorResponse(ctx, result?): Response;
156
+ withContentType(headers, contentType): [string,string][];
157
+ }
158
+ ```
159
+
160
+ ### Route-handler factories
161
+
162
+ Each factory takes an `OptimizedIngressHandler` and returns a
163
+ `(req, srv?) => Response | Promise<Response>` route handler compatible with any
164
+ fetch-style server (Bun, Deno, Node `server.fetch`, …). `srv` is the server
165
+ object used for `requestIP` when `getIp` is provided.
166
+
167
+ | Factory | Wires | Behavior |
168
+ |---------|-------|----------|
169
+ | `readHandler(ingress, opts?)` | GET | Returns the ingress body JSON (200) or a terminal/error response |
170
+ | `headHandler(ingress, opts?)` | HEAD | Same as read, no body |
171
+ | `jsonWriteHandler(ingress, opts?)` | POST/PUT/PATCH | Enforces `Content-Type`, body-size, JSON validity and schema; returns body JSON or 415/413/400/422 |
172
+ | `echoHandler(ingress, opts?)` | POST | Streams the request body back with the client's Content-Type |
173
+ | `deleteHandler(ingress, opts?)` | DELETE | Read-style handler for DELETE requests (same as `readHandler`) |
174
+ | `optionsHandler(ingress, opts?)` | OPTIONS | CORS preflight → 204 (allowed) / 403 from the native pipeline |
175
+ | `fallbackHandler(ingress, opts?)` | any | 404 for unmatched routes |
176
+
177
+ `BakedHandlerOptions`:
178
+
179
+ | Option | Type | Default | Description |
180
+ |--------|------|---------|-------------|
181
+ | `getIp` | `(req, srv) => string \| undefined` | — | Resolve client IP from the server object (e.g. `srv.requestIP`) |
182
+ | `copyBody` | `boolean` | `true` | Copy body slices instead of sharing the native buffer (safe) |
183
+ | `maxBodyBytes` | `number` | `1,048,576` | Body limit for write/echo handlers |
184
+ | `bodyTimeoutMs` | `number` | `30,000` | Overall body-read deadline for write/echo routes (0 = disabled) |
185
+ | `fallback` | `OptimizedIngressHandler` | `ingress` | Handler used for write error paths |
186
+
187
+ ### `createIngressServer(options)` — Bun.serve builder
188
+
189
+ ```ts
190
+ interface CreateIngressServerOptions {
191
+ port: number;
192
+ hostname?: string; // default "0.0.0.0"
193
+ idleTimeout?: number; // default 30
194
+ maxRequestBodySize?: number; // socket-level cap, default 16 MiB
195
+ reusePort?: boolean; // SO_REUSEPORT (with automatic retry on failure)
196
+ copyBody?: boolean; // default true
197
+ getIp?: (req, srv) => string | undefined;
198
+ routes: Record<string, BakedRoute>;
199
+ fallback?: OptimizedIngressHandler; // unmatched routes + OPTIONS fallback
200
+ onError?: (info: { error: Error; request?: Request }) => void; // Node adapter
201
+ }
202
+
203
+ interface BakedRoute {
204
+ read?: OptimizedIngressHandler; // -> GET + HEAD
205
+ write?: OptimizedIngressHandler; // -> POST/PUT/PATCH
206
+ echo?: OptimizedIngressHandler; // -> POST
207
+ cookies?: OptimizedIngressHandler; // -> GET
208
+ delete?: OptimizedIngressHandler; // -> DELETE (read-style)
209
+ maxBodyBytes?: number; // override for this route's write/echo
210
+ bodyTimeoutMs?: number; // overall body-read deadline (0 = disabled)
211
+ }
212
+
213
+ interface BakedServer {
214
+ server: ServerHandle; // runtime-agnostic handle (stop(force?)); Bun exposes the full server via the same object
215
+ stop(): void; // stop accepting; on Bun force-closes, on Node drains then closes
216
+ port: number;
217
+ }
218
+ ```
219
+
220
+ ### `createIngressServerNode(options)` — Node.js adapter
221
+
222
+ Bun remains the primary server target; `createIngressServer` is Bun-only. For
223
+ Node.js consumers, `createIngressServerNode` serves the SAME pre-baked route
224
+ handlers over `node:http` (sharing `buildRouteHandlers`). It returns a
225
+ `NodeIngressServer` (`BakedServer` plus an async `ready: Promise<number>` that
226
+ resolves to the bound port — use it with `port: 0`).
227
+
228
+ ```ts
229
+ import { createIngressServerNode } from "castrum";
230
+ const srv = createIngressServerNode({ port: 3000, routes: { "/health": { read: ingress } } });
231
+ await srv.ready; // listening
232
+ srv.server.stop(true); // force-stop; srv.stop() drains then closes
233
+ ```
234
+
235
+ ### `gracefulShutdown(handles, options)`
236
+
237
+ Wires SIGTERM/SIGINT to a soft-drain-then-force stop for both Bun and Node
238
+ server handles:
239
+
240
+ ```ts
241
+ import { gracefulShutdown } from "castrum";
242
+ const cleanup = gracefulShutdown([srv.server], { timeoutMs: 5_000 });
243
+ // cleanup() removes the signal listeners
244
+ ```
245
+
246
+ ### Observability: metrics, health probes, tracing
247
+
248
+ Enterprise-grade operators get three zero-dependency primitives (all exported
249
+ from the package root):
250
+
251
+ **Metrics** — `createIngressMetrics()` returns a registry + the
252
+ `onRequest`/`onResponse`/`onError` hooks to pass into `createIngressHandler`,
253
+ plus a Prometheus `/metrics` route factory:
254
+
255
+ ```ts
256
+ import { createIngressHandler, createIngressServer,
257
+ createIngressMetrics, metricsHandler,
258
+ livenessHandler, readinessHandler } from "castrum";
259
+
260
+ const metrics = createIngressMetrics();
261
+ const ingress = createIngressHandler(options, { ...metrics.runtime });
262
+
263
+ createIngressServer({
264
+ port: 3000,
265
+ routes: {
266
+ "/healthz": { read: livenessHandler() }, // process alive
267
+ "/readyz": { read: readinessHandler(async () => await dbReady()) }, // deps up
268
+ "/metrics": { read: metricsHandler(metrics) }, // Prometheus text
269
+ "/api": { read: ingress },
270
+ },
271
+ });
272
+ ```
273
+
274
+ Metric names follow `castrum_http_*`:
275
+ `castrum_http_requests_total{method,status}`, `castrum_http_request_duration_seconds{method,status}`,
276
+ `castrum_http_errors_total{code}`, `castrum_http_rate_limited_total`, and
277
+ `castrum_http_in_flight_requests`. The generic registry (`createMetrics()`) is
278
+ also exported for standalone use (counters/gauges/histograms + Prometheus text
279
+ rendering).
280
+
281
+ **Health probes** — `livenessHandler()` (always 200), `readinessHandler(check?)`
282
+ (200/503 via an async dependency check), `healthHandler(check?)`.
283
+
284
+ > **Route spec note**: `BakedRoute.read` also accepts a raw
285
+ > `(req) => Response` function (the probes and `metricsHandler` are plain
286
+ > functions), so the pattern above typechecks and runs on both Bun
287
+ > (`createIngressServer`) and Node (`createIngressServerNode`). Raw handlers
288
+ > are served for GET only and skip the ingress pipeline / OPTIONS preflight.
289
+ > See `examples/basic-server.ts` and the bench ingress server
290
+ > (`bench/servers/ingress-server.ts`), which wire `/metrics` + `/healthz`
291
+ > `/readyz` `/livez` end-to-end.
292
+
293
+ **Tracing** — W3C trace-context helpers: `parseTraceParent(header)`,
294
+ `createTraceId()`, `createSpanId()`, `serializeTraceParent(ctx)`. Wire an
295
+ `onRequest` hook to parse `req.headers.get("traceparent")` and thread
296
+ `traceId`/`spanId` into your structured logger:
297
+
298
+ ### `BakedIngressResult`
299
+
300
+ The zero-alloc result passed to `run()` callbacks. Invalidated after `run()`
301
+ returns — capture what you need inside the callback.
302
+
303
+ | Field | Type | Description |
304
+ |-------|------|-------------|
305
+ | `status` | `number` | HTTP status |
306
+ | `verdict` | `number` | 0 = pass, 1 = terminal |
307
+ | `errorCode` | `number` | `ERR_CODE_*` enum |
308
+ | `headerVariant` | `number` | Precomputed header-set index |
309
+ | `rateRemaining` | `number` | Remaining requests in window |
310
+ | `rateResetMs` / `retryAfterMs` | `number` | Rate-limit reset / retry-after (ms) |
311
+ | `terminal` | `boolean` | Stop processing (error/preflight) |
312
+ | `ok` | `boolean` | 2xx/3xx and no errors |
313
+ | `isPreflight` / `corsAllowed` | `boolean` | CORS preflight state |
314
+ | `rateLimited` / `trustedProxy` | `boolean` | Rate-limit hit / trusted proxy |
315
+ | `bodyValidJson` / `schemaValid` | `boolean` | Body JSON / schema checks |
316
+ | `bodyTruncated` | `boolean` | Output truncated (buffer too small) |
317
+ | `body` | `Uint8Array` | Request body bytes |
318
+ | `bodyJson(copy)` | `Uint8Array` | Metadata/body JSON slice (`copy=true` for a copy) |
319
+
320
+ ### Custom route using the response builders
321
+
322
+ ```ts
323
+ import { createIngressHandler, readHandler } from "castrum";
324
+
325
+ const ingress = createIngressHandler({
326
+ parseCookies: true,
327
+ parseQuery: true,
328
+ cors: { allowOrigin: ["https://app.example.com"], allowCredentials: true },
329
+ rateLimit: { limit: 100, windowMs: 60_000 },
330
+ });
331
+
332
+ Bun.serve({
333
+ port: 3000,
334
+ routes: {
335
+ "/health": { GET: readHandler(ingress) },
336
+ "/v1/custom": {
337
+ GET: (req, srv) =>
338
+ ingress.run(req, undefined, null, (result, ctx) => {
339
+ const terminal = ingress.terminalResponse(req, result, ctx);
340
+ if (terminal) return terminal;
341
+ if (result.rateLimited) {
342
+ return ingress.errorResponse(req, result, 429, "rate_limited", "Too Many Requests", ctx);
343
+ }
344
+ return new Response(result.bodyJson(true), {
345
+ status: 200,
346
+ headers: ingress.responseHeaders(result.headerVariant, ctx.requestIdHeader, ctx.origin),
347
+ });
348
+ }),
349
+ },
350
+ },
351
+ });
352
+ ```
353
+
354
+ ---
355
+
356
+ ## API Reference
357
+
358
+ ### `createIngressFast(options?)`
359
+
360
+ Creates a **synchronous** ingress handler with zero allocations per request. Returns an `IngressFastHandler`.
361
+
362
+ ```ts
363
+ interface IngressFastHandler {
364
+ run<T>(
365
+ req: Request,
366
+ ip: string | undefined,
367
+ body: Uint8Array | null,
368
+ requestId: string,
369
+ fn: (result: FastIngressResult) => T,
370
+ ): T;
371
+ }
372
+ ```
373
+
374
+ **Important**: The callback `fn` must be synchronous. The handler reuses internal buffers and invalidates the result after `fn` returns.
375
+
376
+ ### `createIngress(options?)`
377
+
378
+ Creates an **asynchronous** ingress handler that reads request bodies automatically. Returns an `IngressHandler`.
379
+
380
+ ```ts
381
+ interface IngressHandler {
382
+ (req: Request, ip?: string): Promise<IngressContext>;
383
+ }
384
+
385
+ interface IngressContext extends IngressFastResult {
386
+ response: Response | null; // null if ok, Response if terminal
387
+ }
388
+ ```
389
+
390
+ ### `createIngressSync(options?)`
391
+
392
+ Creates a synchronous handler with the same API as `createIngressFast` but with explicit body/requestId parameters:
393
+
394
+ ```ts
395
+ interface SyncIngressHandler {
396
+ run<T>(
397
+ req: Request,
398
+ ip: string | undefined,
399
+ body: Uint8Array | null,
400
+ requestId: string,
401
+ fn: (result: IngressFastResult) => T,
402
+ ): T;
403
+ }
404
+ ```
405
+
406
+ ---
407
+
408
+ ## Options Reference
409
+
410
+ ### `IngressFastOptions`
411
+
412
+ | Option | Type | Default | Description |
413
+ |--------|------|---------|-------------|
414
+ | `trustProxy` | `boolean` | `false` | Trust `X-Forwarded-For` and `X-Real-IP` headers |
415
+ | `trustedProxies` | `{ enabled, networks? }` | — | Fine-grained proxy trust with optional network whitelist |
416
+ | `parseCookies` | `boolean` | `false` | Parse `Cookie` header into structured JSON |
417
+ | `parseQuery` | `boolean` | `false` | Parse URL query string into structured JSON |
418
+ | `requireJsonBody` | `boolean` | `false` | Require body to be valid JSON |
419
+ | `schema` | `Uint8Array` | — | JSON Schema (as UTF-8 bytes) for body validation |
420
+ | `cors` | `CorsOptions` | — | CORS configuration |
421
+ | `rateLimit` | `{ limit, windowMs, maxEntries }` | — | Rate limiting configuration |
422
+ | `security` | `SecurityHeadersOptions` | — | Security headers configuration |
423
+ | `https` | `boolean` | — | Force HTTPS detection (overrides auto-detection) |
424
+ | `maxBodyBytes` | `number` | 1,048,576 | Maximum body size (1 MB) |
425
+ | `enableSecurityHeaders` | `boolean` | `true` | Enable automatic security headers |
426
+ | `enableRequestIds` | `boolean` | `true` | Generate unique request IDs |
427
+ | `enableBodySizeGuard` | `boolean` | `true` | Guard against oversized bodies |
428
+ | `emitMetadataJson` | `boolean` | `false` | Emit metadata JSON in output (request ID, path, etc.) |
429
+ | `readBody` | `boolean` | — | Explicitly control body reading (auto-detected based on other options) |
430
+ | `outputBufferSize` | `number` | 262,144 | Initial size of the output buffer |
431
+ | `limits` | `LimitsOptions` | — | Fine-grained size limits for URL, headers, cookies, etc. |
432
+
433
+ ### `CorsOptions`
434
+
435
+ | Option | Type | Default | Description |
436
+ |--------|------|---------|-------------|
437
+ | `allowOrigin` | `string[]` | — | Allowed origins (empty or `["*"]` = wildcard) |
438
+ | `allowMethods` | `string[]` | `["GET", "HEAD", "POST"]` | Allowed HTTP methods |
439
+ | `allowHeaders` | `string[]` | — | Allowed request headers |
440
+ | `exposeHeaders` | `string[]` | — | Exposed response headers |
441
+ | `allowCredentials` | `boolean` | `false` | Allow credentials (disables wildcard origin) |
442
+ | `maxAge` | `number` | — | Preflight cache max-age (seconds) |
443
+
444
+ ### `SecurityHeadersOptions`
445
+
446
+ | Option | Type | Default | Description |
447
+ |--------|------|---------|-------------|
448
+ | `contentSecurityPolicy` | `string` | — | `Content-Security-Policy` header value |
449
+ | `hsts` | `boolean` | — | Enable HSTS |
450
+ | `hstsMaxAge` | `number` | 31,536,000 | HSTS max-age (seconds) |
451
+ | `hstsIncludeSubdomains` | `boolean` | `false` | HSTS includeSubDomains |
452
+ | `hstsPreload` | `boolean` | `false` | HSTS preload |
453
+ | `frameOptions` | `string` | `"DENY"` | `X-Frame-Options` value |
454
+ | `nosniff` | `boolean` | `true` | `X-Content-Type-Options: nosniff` |
455
+ | `referrerPolicy` | `string` | `"no-referrer"` | `Referrer-Policy` value |
456
+ | `coep` | `string` | — | `Cross-Origin-Embedder-Policy` |
457
+ | `coop` | `string` | — | `Cross-Origin-Opener-Policy` |
458
+ | `corp` | `string` | — | `Cross-Origin-Resource-Policy` |
459
+ | `xssProtection` | `string` | — | `X-XSS-Protection` value |
460
+
461
+ ### `RateLimitOptions`
462
+
463
+ | Option | Type | Default | Description |
464
+ |--------|------|---------|-------------|
465
+ | `limit` | `number` | — | Maximum requests per window |
466
+ | `windowMs` | `number` | 1000 | Window duration (milliseconds) |
467
+ | `maxEntries` | `number` | — | Maximum tracked IPs (LRU eviction) |
468
+
469
+ ### `LimitsOptions`
470
+
471
+ | Option | Type | Default | Description |
472
+ |--------|------|---------|-------------|
473
+ | `maxUrlBytes` | `number` | 65,536 | Maximum URL length |
474
+ | `maxQueryBytes` | `number` | 16,384 | Maximum query string length |
475
+ | `maxCookieBytes` | `number` | 8,192 | Maximum cookie header length |
476
+ | `maxHeadersBytes` | `number` | 65,536 | Maximum packed headers length |
477
+ | `maxHeaders` | `number` | 100 | Maximum number of headers |
478
+ | `maxPairs` | `number` | 1,024 | Maximum cookie/query key-value pairs |
479
+
480
+ ---
481
+
482
+ ## Result Interface
483
+
484
+ ### `FastIngressResult`
485
+
486
+ | Property | Type | Description |
487
+ |----------|------|-------------|
488
+ | `status` | `number` | HTTP status code |
489
+ | `verdict` | `number` | 0 = pass, 1 = terminal (error) |
490
+ | `flags` | `number` | Bitfield of FLAG_* values |
491
+ | `errorCode` | `number` | Error code enum |
492
+ | `terminal` | `boolean` | True if request processing should stop |
493
+ | `ok` | `boolean` | True if status is 2xx or 3xx and no errors |
494
+ | `https` | `boolean` | Request is HTTPS |
495
+ | `trustedProxy` | `boolean` | Request from trusted proxy |
496
+ | `hasCookies` | `boolean` | Cookie data present in output |
497
+ | `hasQuery` | `boolean` | Query data present in output |
498
+ | `bodyValidJson` | `boolean` | Body is valid JSON |
499
+ | `schemaValid` | `boolean` | Body passes JSON Schema validation |
500
+ | `corsAllowed` | `boolean` | CORS origin allowed |
501
+ | `isPreflight` | `boolean` | Request is a CORS preflight |
502
+ | `rateLimited` | `boolean` | Rate limit exceeded |
503
+ | `rateLimit` | `number` | Rate limit ceiling |
504
+ | `rateRemaining` | `number` | Remaining requests in window |
505
+ | `rateResetMs` | `number` | Timestamp when rate limit resets (ms) |
506
+ | `retryAfterMs` | `number` | Retry-After duration (ms) |
507
+ | `body` | `Uint8Array` | Original request body bytes |
508
+ | `headerVariant` | `number` | Header variant index (pre-computed header set) |
509
+ | `requestId` | `string` | Unique request identifier |
510
+ | `bodyTruncated` | `boolean` | Output data truncated |
511
+ | `cookiesJson()` | `string` | Lazy-decoded cookie JSON string |
512
+ | `queryJson()` | `string` | Lazy-decoded query JSON string |
513
+ | `bodyJson()` | `Uint8Array` | Lazy-decoded body metadata JSON bytes |
514
+
515
+ ---
516
+
517
+ ## Error Codes
518
+
519
+ | Code | HTTP Status | Description |
520
+ |------|-------------|-------------|
521
+ | `ERR_CODE_NONE` (0) | 200 / varies | No error |
522
+ | `ERR_CODE_CORS_PREFLIGHT` (1) | 403 / 204 | CORS preflight rejected (or allowed → 204) |
523
+ | `ERR_CODE_RATE_LIMITED` (2) | 429 | Rate limit exceeded |
524
+ | `ERR_CODE_BODY_TOO_LARGE` (3) | 413 | Body exceeds maxBodyBytes |
525
+ | `ERR_CODE_INVALID_JSON` (4) | 400 | Invalid JSON body |
526
+ | `ERR_CODE_SCHEMA_VALIDATION` (5) | 422 | Schema validation failed |
527
+ | `ERR_CODE_BAD_REQUEST` (6) | 400 | Malformed request |
528
+ | `ERR_CODE_REQUEST_TOO_LARGE` (7) | 431 | Request headers/URL too large |
529
+ | `ERR_CODE_INTERNAL` (8) | 500 | Internal error |
530
+
531
+ ---
532
+
533
+ ## Request ID Generation
534
+
535
+ Request IDs are generated using a counter-based approach (no crypto overhead):
536
+
537
+ ```
538
+ ┌────────────────────────────────────────────┐
539
+ │ 64-bit ID: [hi: 32 bits][lo: 32 bits] │
540
+ │ │
541
+ │ hi = BOOT_RANDOM ^ (WORKER_ID << 16) │
542
+ │ lo = monotonic counter (increments per call)│
543
+ └────────────────────────────────────────────┘
544
+ ```
545
+
546
+ - **Format**: 16-byte hex string (e.g., `"a1b2c3d4e5f6a7b8"`)
547
+ - **Uniqueness**: Unique within a single process instance
548
+ - **Performance**: ~20x faster than `crypto.randomUUID()`
549
+
550
+ ---
551
+
552
+ ## Header Variant System
553
+
554
+ The header template system pre-computes all possible header combinations at initialization time to avoid dynamic header construction per request. Each variant is a bitmask:
555
+
556
+ | Bit | Value | Meaning |
557
+ |-----|-------|---------|
558
+ | 0 | 1 (HV_JSON) | JSON error response headers |
559
+ | 1 | 2 (HV_CORS_SIMPLE) | CORS simple (non-preflight) headers |
560
+ | 2 | 4 (HV_CORS_PREFLIGHT) | CORS preflight headers |
561
+ | 3 | 8 (HV_RATE_ACTIVE) | Rate limit headers (x-ratelimit-*) |
562
+ | 4 | 16 (HV_RATE_LIMITED) | Retry-After header |
563
+
564
+ At request time, Rust computes the appropriate variant index, and TypeScript uses it to look up the pre-built header set.
565
+
566
+ ---
567
+
568
+ ## Complete Example: Custom Middleware
569
+
570
+ ```ts
571
+ import { createIngressFast, FastIngressResult } from "castrum";
572
+
573
+ const handler = createIngressFast({
574
+ parseCookies: true,
575
+ parseQuery: true,
576
+ cors: {
577
+ allowOrigin: ["https://app.example.com"],
578
+ allowMethods: ["GET", "POST", "PUT", "DELETE"],
579
+ allowHeaders: ["Content-Type", "Authorization"],
580
+ allowCredentials: true,
581
+ },
582
+ rateLimit: {
583
+ limit: 1000,
584
+ windowMs: 60_000, // 1 minute
585
+ },
586
+ security: {
587
+ hsts: true,
588
+ hstsMaxAge: 31536000,
589
+ frameOptions: "SAMEORIGIN",
590
+ contentSecurityPolicy: "default-src 'self'",
591
+ },
592
+ });
593
+
594
+ Bun.serve({
595
+ port: 3000,
596
+ async fetch(req) {
597
+ const ip = req.headers.get("x-forwarded-for") ?? "0.0.0.0";
598
+
599
+ return handler.run(req, ip, null, "", (result) => {
600
+ if (result.terminal || !result.ok) {
601
+ // Build error response using the status from the result
602
+ return new Response(
603
+ JSON.stringify({
604
+ error: true,
605
+ code: result.errorCode,
606
+ message: getErrorMessage(result),
607
+ }),
608
+ {
609
+ status: result.status,
610
+ headers: {
611
+ "content-type": "application/json",
612
+ ...getCorsHeaders(result),
613
+ },
614
+ },
615
+ );
616
+ }
617
+
618
+ // Access parsed data
619
+ if (result.hasCookies) {
620
+ const cookies = JSON.parse(result.cookiesJson());
621
+ // use cookies...
622
+ }
623
+
624
+ if (result.hasQuery) {
625
+ const query = JSON.parse(result.queryJson());
626
+ // use query params...
627
+ }
628
+
629
+ // Successful request
630
+ return new Response("Hello, world!", {
631
+ status: 200,
632
+ headers: { "content-type": "text/plain" },
633
+ });
634
+ });
635
+ },
636
+ });
637
+
638
+ function getErrorMessage(result: FastIngressResult): string {
639
+ const messages: Record<number, string> = {
640
+ 1: "CORS preflight rejected",
641
+ 2: "Too many requests",
642
+ 3: "Request body too large",
643
+ 4: "Invalid JSON body",
644
+ 5: "Schema validation failed",
645
+ 6: "Bad request",
646
+ 7: "Request too large",
647
+ 8: "Internal server error",
648
+ };
649
+ return messages[result.errorCode] ?? "Unknown error";
650
+ }
651
+
652
+ function getCorsHeaders(result: FastIngressResult): Record<string, string> {
653
+ if (result.corsAllowed) {
654
+ return {
655
+ "access-control-allow-origin": "https://app.example.com",
656
+ "access-control-allow-credentials": "true",
657
+ };
658
+ }
659
+ return {};
660
+ }
661
+
662
+ ---
663
+
664
+ ## Framework Integration (Bun backend frameworks)
665
+
666
+ `castrum` ships framework-agnostic adapters (`src/integration/`) so you can embed
667
+ the ingress pipeline as a request stage in **Hono**, **Elysia**, or a plain
668
+ `Bun.serve` handler — no router changes needed.
669
+
670
+ ### `createPipeline` — run the ingress as a request stage
671
+
672
+ `createPipeline` wraps `createIngressHandler` (path 2) and exposes:
673
+
674
+ - `handleRequest(req, ip?)` → `Promise<Response>` — a **fetch-compatible**
675
+ handler: terminal (rate-limited / CORS-denied / schema-failed / bad body)
676
+ requests get their error response, OK requests get a rendered response
677
+ (default `{"ok":true,"requestId":...}`, or your own `render`).
678
+ - `preprocess(req, ip?)` → `Promise<PreprocessOutcome>` — the **middleware
679
+ seam**: `{ terminal, response, result, ctx }`. When `terminal`, serve
680
+ `response`; otherwise continue to your app handler with the snapshotted
681
+ `result` and a per-request `ctx` (`requestId`, `ip`, `locals` map).
682
+ - `readBody(req)` — stream-read a body with the configured limits.
683
+
684
+ ```ts
685
+ import { createPipeline } from "castrum";
686
+
687
+ // Plain Bun.serve — the whole pipeline behind a fetch handler
688
+ const pipeline = createPipeline({ options: { parseCookies: true } });
689
+ Bun.serve({ port: 3000, fetch: (req) => pipeline.handleRequest(req) });
690
+ ```
691
+
692
+ ```ts
693
+ // Hono-style middleware: short-circuit on terminal, else pass context through
694
+ const pipeline = createPipeline({
695
+ options: { rateLimit: { limit: 100, windowMs: 60_000 } },
696
+ });
697
+
698
+ app.use("*", async (c, next) => {
699
+ const { terminal, response, ctx } = await pipeline.preprocess(c.req.raw);
700
+ if (terminal && response) return response; // 429 / 403 / 422 / 413
701
+ c.set("ingress", ctx); // requestId, ip, locals
702
+ await next();
703
+ });
704
+ ```
705
+
706
+ ### `createWebSocketUpgrade` — RFC 6455 upgrade handshake
707
+
708
+ `createWebSocketUpgrade(req, { protocols })` validates `Sec-WebSocket-Key`,
709
+ computes the accept key (native `rust.wsAcceptKey`), negotiates a subprotocol,
710
+ and returns `{ response, key, protocol }` or `null` when the request isn't a
711
+ valid upgrade. Pair it with your framework's own WebSocket server:
712
+
713
+ ```ts
714
+ import { createWebSocketUpgrade } from "castrum";
715
+
716
+ Bun.serve({
717
+ port: 3000,
718
+ fetch(req, server) {
719
+ const up = createWebSocketUpgrade(req, { protocols: ["chat"] });
720
+ if (up && server.upgrade(req, { data: { protocol: up.protocol } })) {
721
+ return up.response; // 101 Switching Protocols
722
+ }
723
+ return new Response("Not a websocket", { status: 400 });
724
+ },
725
+ websocket: { message(ws, msg) { ws.send(msg); } },
726
+ });
727
+ ```
728
+
729
+ ### `sseResponse` — server-sent events on the fast path
730
+
731
+ `sseResponse(events, init)` frames an (async) iterable of events with the
732
+ native `rust.sseEncodeEvent` and returns a `text/event-stream` `Response`:
733
+
734
+ ```ts
735
+ import { sseResponse } from "castrum";
736
+
737
+ async function* tick() {
738
+ for (let i = 0; i < 5; i++) {
739
+ await Bun.sleep(250);
740
+ yield { event: "tick", data: String(i) };
741
+ }
742
+ }
743
+ return sseResponse(tick());
744
+ ```
745
+ }