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