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
package/docs/INGRESS.md
ADDED
|
@@ -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 & 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
|
+
```
|