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.
- package/LICENSE +21 -0
- package/README.md +322 -59
- package/castrum.linux-x64-gnu.node +0 -0
- package/dist/index.d.ts +3161 -0
- package/dist/index.js +7306 -0
- package/docs/ARCHITECTURE.md +415 -0
- package/docs/BENCHMARKS.md +307 -0
- package/docs/ENVIRONMENT.md +79 -0
- package/docs/GETTING_STARTED.md +209 -0
- package/docs/INGRESS.md +745 -0
- package/docs/REPO_MAP.md +259 -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 +42 -0
- package/docs/bun-builtins-decision-matrix.md +108 -0
- package/index.ts +51 -8
- package/package.json +73 -20
- package/src/data/json-rows.ts +13 -13
- package/src/ingress/body.ts +149 -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 +563 -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 +67 -0
- package/src/ingress/index.ts +130 -405
- package/src/ingress/metrics.ts +132 -0
- package/src/ingress/options.ts +197 -0
- package/src/ingress/packing/gather-raw-headers.ts +75 -0
- package/src/ingress/packing/header-packing.ts +75 -0
- package/src/ingress/packing/input-packer.ts +150 -0
- package/src/ingress/packing/scratch.ts +50 -0
- package/src/ingress/packing/select-headers.ts +96 -0
- package/src/ingress/response/error-bodies.ts +75 -0
- package/src/ingress/response/terminal.ts +59 -0
- package/src/ingress/routes/common.ts +38 -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 +37 -0
- package/src/ingress/routes/index.ts +10 -0
- package/src/ingress/routes/json-write.ts +153 -0
- package/src/ingress/routes/options.ts +36 -0
- package/src/ingress/routes/read.ts +45 -0
- package/src/ingress/server-node.ts +376 -0
- package/src/ingress/server.ts +443 -0
- package/src/ingress/shared.ts +132 -0
- package/src/ingress/status.ts +97 -0
- package/src/ingress/types.ts +157 -0
- package/src/integration/batch.ts +58 -0
- package/src/integration/index.ts +29 -0
- package/src/integration/pipeline.ts +217 -0
- package/src/integration/streaming.ts +79 -0
- package/src/integration/websocket.ts +92 -0
- package/src/loader/batch.ts +133 -0
- package/src/loader/cost.ts +104 -0
- package/src/loader/index.ts +930 -0
- package/src/loader/ops.ts +657 -0
- package/src/native/ffi.ts +1541 -0
- package/src/native/index.ts +8 -207
- package/src/native/loader.ts +194 -0
- package/src/native/types.ts +598 -0
- package/src/rust-ffi/addon.ts +10 -0
- package/src/rust-ffi/batch.ts +431 -0
- package/src/rust-ffi/client.ts +88 -0
- package/src/rust-ffi/context.ts +164 -0
- package/src/rust-ffi/index.ts +40 -541
- package/src/rust-ffi/options.ts +113 -0
- package/src/rust-ffi/packed.ts +253 -0
- package/src/rust-ffi/proven.ts +42 -0
- package/src/rust-ffi/scalar/crypto.ts +265 -0
- package/src/rust-ffi/scalar/factories.ts +233 -0
- package/src/rust-ffi/scalar/hashing.ts +72 -0
- package/src/rust-ffi/scalar/http.ts +206 -0
- package/src/rust-ffi/scalar/index.ts +31 -0
- package/src/rust-ffi/scalar/interface.ts +450 -0
- package/src/rust-ffi/scalar/json.ts +45 -0
- package/src/rust-ffi/scalar/payload.ts +124 -0
- package/src/rust-ffi/text.ts +95 -0
- package/src/selection.json +194 -0
- package/src/selection.ts +77 -0
- package/src/shared/adaptive.ts +68 -0
- package/src/shared/bench-classify.ts +209 -0
- package/src/shared/buffer-pool.ts +209 -0
- package/src/shared/bytes.ts +35 -5
- package/src/shared/env.ts +20 -0
- package/src/shared/log.ts +74 -0
- package/src/shared/metrics.ts +247 -0
- package/src/shared/packed.ts +350 -154
- package/src/shared/proven.ts +596 -0
- package/src/shared/request-id.ts +67 -0
- package/src/shared/response.ts +104 -0
- package/src/shared/runtime.ts +34 -0
- package/src/shared/trace.ts +81 -0
- package/src/shared/uuid.ts +31 -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/ingress/packed-input.ts +0 -59
- package/src/shared/json.ts +0 -18
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
// src/ingress/server.ts — Bun.serve builder over pre-baked route handlers.
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
readHandler,
|
|
5
|
+
headHandler,
|
|
6
|
+
jsonWriteHandler,
|
|
7
|
+
echoHandler,
|
|
8
|
+
fallbackHandler,
|
|
9
|
+
deleteHandler,
|
|
10
|
+
optionsHandler,
|
|
11
|
+
} from './routes'
|
|
12
|
+
import type { BakedHandlerOptions } from './routes/common'
|
|
13
|
+
import type { OptimizedIngressHandler } from './types'
|
|
14
|
+
import { isBun } from '../shared/runtime'
|
|
15
|
+
|
|
16
|
+
/** Server-level default for the socket request-body cap (16 MiB). */
|
|
17
|
+
export const DEFAULT_MAX_REQUEST_BODY_SIZE = 16 * 1024 * 1024
|
|
18
|
+
|
|
19
|
+
/** A raw web-standard route handler (`Request` → `Response`), e.g. the
|
|
20
|
+
* health/metrics probes (`livenessHandler`, `readinessHandler`,
|
|
21
|
+
* `healthHandler`, `metricsHandler`). Accepted as `BakedRoute.read` and
|
|
22
|
+
* served for GET directly, outside the ingress pipeline. The optional third
|
|
23
|
+
* arg carries the matched dynamic path params (`:param`/`*`) on the Node
|
|
24
|
+
* adapter (Bun's native router handles matching; the ingress pipeline does
|
|
25
|
+
* not echo params in its response). */
|
|
26
|
+
export type RouteHandler = (
|
|
27
|
+
req: Request,
|
|
28
|
+
srv?: unknown,
|
|
29
|
+
params?: Record<string, string>,
|
|
30
|
+
) => Response | Promise<Response>
|
|
31
|
+
|
|
32
|
+
/** Route spec for the Bun.serve builder. */
|
|
33
|
+
export interface BakedRoute {
|
|
34
|
+
/**
|
|
35
|
+
* Wires GET + HEAD read handlers. Accepts an optimized ingress handler OR a
|
|
36
|
+
* raw `RouteHandler` function (health/metrics probes are raw — see
|
|
37
|
+
* src/ingress/health.ts). A raw handler is served for GET only.
|
|
38
|
+
*/
|
|
39
|
+
read?: OptimizedIngressHandler | RouteHandler
|
|
40
|
+
/** Wires POST/PUT/PATCH JSON-write handlers (and OPTIONS fallback). */
|
|
41
|
+
write?: OptimizedIngressHandler
|
|
42
|
+
/** Wires a POST echo handler. */
|
|
43
|
+
echo?: OptimizedIngressHandler
|
|
44
|
+
/** Wires a GET read handler (cookies-style route). */
|
|
45
|
+
cookies?: OptimizedIngressHandler
|
|
46
|
+
/** Wires a DELETE read-style handler. */
|
|
47
|
+
delete?: OptimizedIngressHandler
|
|
48
|
+
/** Overrides `maxBodyBytes` for this route's write/echo handlers. */
|
|
49
|
+
maxBodyBytes?: number
|
|
50
|
+
/**
|
|
51
|
+
* Overall deadline (ms) for reading the request body on this route's
|
|
52
|
+
* write/echo handlers. Default: 30_000 (`DEFAULT_BODY_TIMEOUT_MS`).
|
|
53
|
+
* Set 0 to disable.
|
|
54
|
+
*/
|
|
55
|
+
bodyTimeoutMs?: number
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** RFC 6455 handshake values returned by `CreateIngressServerOptions.upgrade`. */
|
|
59
|
+
export interface NodeUpgradeHandshake {
|
|
60
|
+
/** `Sec-WebSocket-Accept` value (compute via `rust.text.wsAcceptKey`). */
|
|
61
|
+
accept: string
|
|
62
|
+
/** Optional negotiated subprotocol (`Sec-WebSocket-Protocol`). */
|
|
63
|
+
protocol?: string
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Options for {@link createIngressServer}. */
|
|
67
|
+
export interface CreateIngressServerOptions {
|
|
68
|
+
port: number
|
|
69
|
+
hostname?: string
|
|
70
|
+
idleTimeout?: number
|
|
71
|
+
maxRequestBodySize?: number
|
|
72
|
+
reusePort?: boolean
|
|
73
|
+
copyBody?: boolean
|
|
74
|
+
getIp?: (req: Request, srv: unknown) => string | undefined
|
|
75
|
+
routes: Record<string, BakedRoute>
|
|
76
|
+
fallback?: OptimizedIngressHandler
|
|
77
|
+
/**
|
|
78
|
+
* Invoked when an unhandled error escapes a request handler (currently wired
|
|
79
|
+
* on the Node adapter; the Bun path uses runtime.onResponse). Never throws —
|
|
80
|
+
* hook failures are swallowed.
|
|
81
|
+
*/
|
|
82
|
+
onError?: (info: { error: Error; request?: Request }) => void
|
|
83
|
+
/** Node adapter only: ms to receive the complete request (headers + body).
|
|
84
|
+
* Guards slowloris/trickling requests. Default: 30_000. */
|
|
85
|
+
requestTimeoutMs?: number
|
|
86
|
+
/** Node adapter only: ms to receive the request headers. Default: 30_000. */
|
|
87
|
+
headersTimeoutMs?: number
|
|
88
|
+
/** Node adapter only: max requests per keep-alive socket before Node forces
|
|
89
|
+
* a fresh connection (bounds long-lived socket reuse). Default: 1000. */
|
|
90
|
+
maxRequestsPerSocket?: number
|
|
91
|
+
/** Node adapter only: optional WebSocket upgrade handler. Return the RFC 6455
|
|
92
|
+
* handshake values (see {@link NodeUpgradeHandshake}) to complete the
|
|
93
|
+
* handshake on the hijacked socket; return null/undefined to decline the
|
|
94
|
+
* upgrade. Ignored by the Bun server. (`createWebSocketUpgrade` is Bun-only
|
|
95
|
+
* — Node's Response cannot carry a 101.) */
|
|
96
|
+
upgrade?: (req: Request) => NodeUpgradeHandshake | null | undefined
|
|
97
|
+
/** Node adapter only: called after a WebSocket upgrade handshake completes
|
|
98
|
+
* (the 101 response was accepted), with the hijacked socket so the caller
|
|
99
|
+
* can attach a frame codec/message loop (e.g. the `ws` library). Ignored by
|
|
100
|
+
* the Bun server. */
|
|
101
|
+
onUpgrade?: (socket: import('node:stream').Duplex, req: unknown, head: Buffer) => void
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Minimal surface of the running server that castrum relies on.
|
|
106
|
+
*
|
|
107
|
+
* This is intentionally NOT `ReturnType<typeof Bun.serve>` so that the public
|
|
108
|
+
* types stay runtime-agnostic — Node.js TypeScript consumers don't need
|
|
109
|
+
* `@types/bun` just to import castrum's types. Bun's server exposes far more
|
|
110
|
+
* fields; treat the handle as opaque.
|
|
111
|
+
*/
|
|
112
|
+
export interface ServerHandle {
|
|
113
|
+
/** The port the server is bound to, when known (Bun exposes it; Node's
|
|
114
|
+
* http.Server does not — use `BakedServer.port` instead). */
|
|
115
|
+
port?: number
|
|
116
|
+
/** Force-stop the server. Bun semantics: `true` drops active connections. */
|
|
117
|
+
stop(force?: boolean): void
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** A running server instance plus a stop helper. */
|
|
121
|
+
export interface BakedServer {
|
|
122
|
+
server: ServerHandle
|
|
123
|
+
stop(): void
|
|
124
|
+
port: number
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export interface GracefulShutdownOptions {
|
|
128
|
+
/** Grace period (ms) to drain in-flight requests before force-closing. */
|
|
129
|
+
timeoutMs?: number
|
|
130
|
+
/** Signals to listen for. Default: SIGTERM + SIGINT. */
|
|
131
|
+
signals?: NodeJS.Signals[]
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Wire SIGTERM/SIGINT to a graceful shutdown: soft-stop (drain in-flight
|
|
136
|
+
* requests) then force-close after `timeoutMs`. Works with both the Bun and
|
|
137
|
+
* Node server handles (both expose `stop(force?)`).
|
|
138
|
+
*
|
|
139
|
+
* Returns a cleanup function that removes the signal listeners. Call it (or
|
|
140
|
+
* `process.exit`) to tear down without hanging.
|
|
141
|
+
*/
|
|
142
|
+
export function gracefulShutdown(
|
|
143
|
+
handles: ReadonlyArray<ServerHandle>,
|
|
144
|
+
options: GracefulShutdownOptions = {},
|
|
145
|
+
): () => void {
|
|
146
|
+
const timeoutMs = options.timeoutMs ?? 5_000
|
|
147
|
+
const signals = options.signals ?? ['SIGTERM', 'SIGINT']
|
|
148
|
+
|
|
149
|
+
let shuttingDown = false
|
|
150
|
+
const handler = () => {
|
|
151
|
+
if (shuttingDown) return
|
|
152
|
+
shuttingDown = true
|
|
153
|
+
// Soft stop: stop accepting new work and drain in-flight requests.
|
|
154
|
+
for (const h of handles) {
|
|
155
|
+
try {
|
|
156
|
+
h.stop(false)
|
|
157
|
+
} catch {
|
|
158
|
+
// ignore already-stopped handles
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
// Force-close after the grace period.
|
|
162
|
+
const timer = setTimeout(() => {
|
|
163
|
+
for (const h of handles) {
|
|
164
|
+
try {
|
|
165
|
+
h.stop(true)
|
|
166
|
+
} catch {
|
|
167
|
+
// ignore
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}, timeoutMs)
|
|
171
|
+
timer.unref?.()
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
for (const sig of signals) {
|
|
175
|
+
process.on(sig, handler)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return () => {
|
|
179
|
+
for (const sig of signals) {
|
|
180
|
+
process.removeListener(sig, handler)
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Build the route → { method → handler } map from a server spec.
|
|
187
|
+
*
|
|
188
|
+
* Shared by `createIngressServer` (Bun.serve) and `createIngressServerNode`
|
|
189
|
+
* (node:http) so both runtimes wire the exact same route handlers.
|
|
190
|
+
*/
|
|
191
|
+
|
|
192
|
+
/** Options `buildRouteHandlers` actually consumes (a subset of the server
|
|
193
|
+
* options — it does not need `port` or the Node-only server fields). */
|
|
194
|
+
export type BuildRouteHandlersOptions = Pick<
|
|
195
|
+
CreateIngressServerOptions,
|
|
196
|
+
'routes' | 'fallback' | 'getIp' | 'copyBody'
|
|
197
|
+
>
|
|
198
|
+
|
|
199
|
+
export function buildRouteHandlers(options: BuildRouteHandlersOptions): {
|
|
200
|
+
routes: Record<string, Record<string, unknown>>
|
|
201
|
+
baseOpts: BakedHandlerOptions
|
|
202
|
+
} {
|
|
203
|
+
const baseOpts: BakedHandlerOptions = {
|
|
204
|
+
getIp: options.getIp,
|
|
205
|
+
copyBody: options.copyBody,
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const serverRoutes: Record<string, Record<string, unknown>> = {}
|
|
209
|
+
|
|
210
|
+
for (const [path, spec] of Object.entries(options.routes)) {
|
|
211
|
+
const routeOpts: BakedHandlerOptions = {
|
|
212
|
+
...baseOpts,
|
|
213
|
+
maxBodyBytes: spec.maxBodyBytes,
|
|
214
|
+
bodyTimeoutMs: spec.bodyTimeoutMs,
|
|
215
|
+
}
|
|
216
|
+
const methods: Record<string, unknown> = {}
|
|
217
|
+
|
|
218
|
+
if (spec.read) {
|
|
219
|
+
if (typeof spec.read === 'function') {
|
|
220
|
+
// Raw request→Response handler (probes, /metrics): serve GET directly,
|
|
221
|
+
// outside the ingress pipeline. HEAD is intentionally not wired (a raw
|
|
222
|
+
// handler returns a body; probes use GET).
|
|
223
|
+
methods.GET = spec.read
|
|
224
|
+
} else {
|
|
225
|
+
methods.GET = readHandler(spec.read, routeOpts)
|
|
226
|
+
methods.HEAD = headHandler(spec.read, routeOpts)
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (spec.write) {
|
|
231
|
+
const writeOpts: BakedHandlerOptions = {
|
|
232
|
+
...routeOpts,
|
|
233
|
+
fallback: options.fallback ?? spec.write,
|
|
234
|
+
}
|
|
235
|
+
methods.POST = jsonWriteHandler(spec.write, writeOpts)
|
|
236
|
+
methods.PUT = jsonWriteHandler(spec.write, writeOpts)
|
|
237
|
+
methods.PATCH = jsonWriteHandler(spec.write, writeOpts)
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (spec.echo) {
|
|
241
|
+
methods.POST = echoHandler(spec.echo, routeOpts)
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (spec.cookies) {
|
|
245
|
+
methods.GET = readHandler(spec.cookies, routeOpts)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (spec.delete) {
|
|
249
|
+
methods.DELETE = deleteHandler(spec.delete, routeOpts)
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// CORS preflight (OPTIONS) is served for EVERY route with a NATIVE handler
|
|
253
|
+
// (not just write routes with a fallback), so read-only routes also answer
|
|
254
|
+
// preflights with 204/403 from the native pipeline. Raw probe handlers
|
|
255
|
+
// (plain functions) are excluded — browsers don't preflight probes.
|
|
256
|
+
const primary =
|
|
257
|
+
spec.delete ??
|
|
258
|
+
(spec.read && typeof spec.read !== 'function' ? spec.read : undefined) ??
|
|
259
|
+
spec.cookies ??
|
|
260
|
+
spec.write ??
|
|
261
|
+
spec.echo
|
|
262
|
+
if (primary) {
|
|
263
|
+
methods.OPTIONS = optionsHandler(primary, routeOpts)
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
serverRoutes[path] = methods
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return { routes: serverRoutes, baseOpts }
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** A matched dynamic route: the handler map + the extracted path params. */
|
|
273
|
+
export interface PathMatch {
|
|
274
|
+
methods: Record<string, unknown>
|
|
275
|
+
params: Record<string, string>
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
interface CompiledDynamicPattern {
|
|
279
|
+
re: RegExp
|
|
280
|
+
paramNames: string[]
|
|
281
|
+
rest: boolean
|
|
282
|
+
methods: Record<string, unknown>
|
|
283
|
+
/** Number of literal (static) segments — used to prefer the most specific pattern. */
|
|
284
|
+
staticSegments: number
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Build a path matcher over a route map, supporting `:param` and `*` (rest)
|
|
289
|
+
* segments. Exact (static) paths always win over dynamic patterns; dynamic
|
|
290
|
+
* patterns are ordered most-specific-first (most static segments, then fewest
|
|
291
|
+
* params). Used by `createIngressServerNode` (Bun's native router already
|
|
292
|
+
* matches dynamic patterns itself). The ingress pipeline does not echo path
|
|
293
|
+
* params in its response, so matching only selects the handler.
|
|
294
|
+
*/
|
|
295
|
+
export function buildPathMatcher(
|
|
296
|
+
routes: Record<string, Record<string, unknown>>,
|
|
297
|
+
): (pathname: string) => PathMatch | undefined {
|
|
298
|
+
const exact = new Map<string, Record<string, unknown>>()
|
|
299
|
+
const dynamic: CompiledDynamicPattern[] = []
|
|
300
|
+
|
|
301
|
+
for (const [path, methods] of Object.entries(routes)) {
|
|
302
|
+
const hasParam = path.indexOf(':') !== -1
|
|
303
|
+
const hasRest = path.indexOf('*') !== -1
|
|
304
|
+
if (!hasParam && !hasRest) {
|
|
305
|
+
exact.set(path, methods)
|
|
306
|
+
continue
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const paramNames: string[] = []
|
|
310
|
+
let rest = false
|
|
311
|
+
let staticSegments = 0
|
|
312
|
+
const parts = path.split('/').filter((s) => s.length > 0)
|
|
313
|
+
const source = parts
|
|
314
|
+
.map((seg) => {
|
|
315
|
+
if (seg === '*') {
|
|
316
|
+
rest = true
|
|
317
|
+
return '(.*)'
|
|
318
|
+
}
|
|
319
|
+
if (seg.startsWith(':')) {
|
|
320
|
+
paramNames.push(seg.slice(1))
|
|
321
|
+
return '([^/]+)'
|
|
322
|
+
}
|
|
323
|
+
staticSegments++
|
|
324
|
+
return seg.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
325
|
+
})
|
|
326
|
+
.join('/')
|
|
327
|
+
|
|
328
|
+
dynamic.push({
|
|
329
|
+
re: new RegExp(`^/${source}${rest ? '' : '/?$'}`),
|
|
330
|
+
paramNames,
|
|
331
|
+
rest,
|
|
332
|
+
methods,
|
|
333
|
+
staticSegments,
|
|
334
|
+
})
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
dynamic.sort(
|
|
338
|
+
(a, b) => b.staticSegments - a.staticSegments || a.paramNames.length - b.paramNames.length,
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
return (pathname: string) => {
|
|
342
|
+
const exactHit = exact.get(pathname)
|
|
343
|
+
if (exactHit !== undefined) {
|
|
344
|
+
return { methods: exactHit, params: {} }
|
|
345
|
+
}
|
|
346
|
+
for (const d of dynamic) {
|
|
347
|
+
const m = d.re.exec(pathname)
|
|
348
|
+
if (m !== null) {
|
|
349
|
+
const params: Record<string, string> = {}
|
|
350
|
+
for (let i = 0; i < d.paramNames.length; i++) {
|
|
351
|
+
const raw = m[i + 1]
|
|
352
|
+
if (raw !== undefined) params[d.paramNames[i] ?? ''] = safeDecode(raw)
|
|
353
|
+
}
|
|
354
|
+
if (d.rest) {
|
|
355
|
+
const restRaw = m[d.paramNames.length + 1]
|
|
356
|
+
if (restRaw !== undefined) params['*'] = safeDecode(restRaw)
|
|
357
|
+
}
|
|
358
|
+
return { methods: d.methods, params }
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
return undefined
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function safeDecode(value: string): string {
|
|
366
|
+
try {
|
|
367
|
+
return decodeURIComponent(value)
|
|
368
|
+
} catch {
|
|
369
|
+
return value
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** Typed options forwarded to `Bun.serve`. */
|
|
374
|
+
interface BunServerOptions {
|
|
375
|
+
hostname: string
|
|
376
|
+
port: number
|
|
377
|
+
idleTimeout: number
|
|
378
|
+
routes: Record<string, Record<string, unknown>>
|
|
379
|
+
maxRequestBodySize: number
|
|
380
|
+
reusePort?: boolean
|
|
381
|
+
fetch?: (req: Request) => Response | Promise<Response>
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** Build a Bun.serve config from pre-baked route handlers. */
|
|
385
|
+
export function createIngressServer(options: CreateIngressServerOptions): BakedServer {
|
|
386
|
+
// Bun-only by design: this builder targets Bun.serve's Routes API. Node
|
|
387
|
+
// consumers use `createIngressServerNode` with the same route handlers
|
|
388
|
+
// (sharing `buildRouteHandlers`).
|
|
389
|
+
if (!isBun()) {
|
|
390
|
+
throw new TypeError(
|
|
391
|
+
'createIngressServer is Bun-only (it builds a Bun.serve config). ' +
|
|
392
|
+
'On Node.js use createIngressServerNode with the same route handlers.',
|
|
393
|
+
)
|
|
394
|
+
}
|
|
395
|
+
const { routes: serverRoutes, baseOpts } = buildRouteHandlers(options)
|
|
396
|
+
|
|
397
|
+
const serverOptions: BunServerOptions = {
|
|
398
|
+
hostname: options.hostname ?? '0.0.0.0',
|
|
399
|
+
port: options.port,
|
|
400
|
+
idleTimeout: options.idleTimeout ?? 30,
|
|
401
|
+
routes: serverRoutes,
|
|
402
|
+
// Socket-level request-size guard. Bun's default is ~128 MiB; we default
|
|
403
|
+
// to 16 MiB so an oversized request is rejected at the socket without ever
|
|
404
|
+
// being buffered (route handlers enforce the tighter `maxBodyBytes`).
|
|
405
|
+
maxRequestBodySize: options.maxRequestBodySize ?? DEFAULT_MAX_REQUEST_BODY_SIZE,
|
|
406
|
+
}
|
|
407
|
+
if (options.reusePort) {
|
|
408
|
+
serverOptions.reusePort = true
|
|
409
|
+
}
|
|
410
|
+
if (options.fallback) {
|
|
411
|
+
serverOptions.fetch = fallbackHandler(options.fallback, baseOpts)
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// Narrow bridge to Bun's (version-dependent) Serve type — Bun's Routes /
|
|
415
|
+
// FetchHandler shapes are complex, so we cast once instead of `as any`.
|
|
416
|
+
let server: ReturnType<typeof Bun.serve>
|
|
417
|
+
const init = serverOptions as Parameters<typeof Bun.serve>[0]
|
|
418
|
+
try {
|
|
419
|
+
server = Bun.serve(init)
|
|
420
|
+
} catch (err) {
|
|
421
|
+
if (options.reusePort) {
|
|
422
|
+
delete serverOptions.reusePort
|
|
423
|
+
server = Bun.serve(serverOptions as Parameters<typeof Bun.serve>[0])
|
|
424
|
+
} else {
|
|
425
|
+
throw err
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
return {
|
|
430
|
+
server,
|
|
431
|
+
stop: () => {
|
|
432
|
+
try {
|
|
433
|
+
server.stop(true)
|
|
434
|
+
} catch {
|
|
435
|
+
// already stopped
|
|
436
|
+
}
|
|
437
|
+
},
|
|
438
|
+
// The ACTUAL bound port (not `options.port`): with `port: 0` Bun
|
|
439
|
+
// auto-assigns a free port, and consumers expect the real listening port
|
|
440
|
+
// (parity with the Node adapter's `ready`/port behavior).
|
|
441
|
+
port: typeof server.port === 'number' ? server.port : options.port,
|
|
442
|
+
}
|
|
443
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// src/ingress/shared.ts — Constants and types shared by BOTH ingress paths
|
|
2
|
+
// (the fast packed-input path and the pre-baked handler path).
|
|
3
|
+
//
|
|
4
|
+
// Binary-layout constants read from Rust live in ./constants.ts (the single
|
|
5
|
+
// numeric source of truth). This module holds the JS-level constants and types
|
|
6
|
+
// that both ingress paths rely on, so neither path needs to depend on the
|
|
7
|
+
// other's implementation module.
|
|
8
|
+
|
|
9
|
+
/** Default maximum request body size in bytes. */
|
|
10
|
+
export const DEFAULT_MAX_BODY_BYTES = 1_048_576
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Default native ingress output-buffer size (bytes) for the FAST path
|
|
14
|
+
* (`createIngressFast`).
|
|
15
|
+
*
|
|
16
|
+
* The fast path allocates its output buffer ONCE in the factory and reuses it
|
|
17
|
+
* across `run()` calls, so this default sets headroom for large responses
|
|
18
|
+
* (avoiding per-request growth) rather than per-request allocation cost.
|
|
19
|
+
* Perf-sensitive — change only with benchmarking.
|
|
20
|
+
*/
|
|
21
|
+
export const DEFAULT_FAST_OUTPUT_BUFFER_SIZE = 262_144
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Default native ingress output-buffer size (bytes) for the PRE-BAKED path
|
|
25
|
+
* (`createIngressHandler`).
|
|
26
|
+
*
|
|
27
|
+
* The pre-baked path POOLS output buffers across requests (`BufferPool`), so
|
|
28
|
+
* a smaller default keeps peak memory lower. Perf-sensitive — change only
|
|
29
|
+
* with benchmarking. The two paths intentionally differ (see above).
|
|
30
|
+
*/
|
|
31
|
+
export const DEFAULT_BAKED_OUTPUT_BUFFER_SIZE = 131_072
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Upper bound on ingress native output buffers (64 MiB). A huge misconfigured
|
|
35
|
+
* `outputBufferSize` would otherwise trigger a multi-GB native allocation
|
|
36
|
+
* (`handle_request_full_sync` allocates `vec![0u8; output_size]`), OOM-ing the
|
|
37
|
+
* process. Callers that genuinely need more can raise it here.
|
|
38
|
+
*/
|
|
39
|
+
export const MAX_OUTPUT_BUFFER_SIZE = 64 * 1024 * 1024
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Clamp an ingress output-buffer size into `[min, MAX_OUTPUT_BUFFER_SIZE]` so
|
|
43
|
+
* a misconfigured (huge/negative/NaN) value can never reach the native layer.
|
|
44
|
+
*/
|
|
45
|
+
export function clampIngressBufferSize(size: number, min: number): number {
|
|
46
|
+
return Math.min(MAX_OUTPUT_BUFFER_SIZE, Math.max(min, Math.floor(size)))
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Default overall deadline (ms) for reading a request body. Non-zero so the
|
|
51
|
+
* async `createIngress` / route handlers are protected against slowloris and
|
|
52
|
+
* trickling bodies by default. Set `bodyTimeoutMs: 0` to disable.
|
|
53
|
+
*/
|
|
54
|
+
export const DEFAULT_BODY_TIMEOUT_MS = 30_000
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Convert a millisecond duration to whole seconds, rounding UP.
|
|
58
|
+
*
|
|
59
|
+
* Used everywhere a `ratelimit-reset` / `Retry-After` value is emitted: the
|
|
60
|
+
* native pipeline reports millisecond resolution, and HTTP rate-limit headers
|
|
61
|
+
* are whole-second integers (a partial second must not report 0).
|
|
62
|
+
*/
|
|
63
|
+
export function secondsFromMs(ms: number): number {
|
|
64
|
+
return Math.ceil(ms / 1000)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Maps HTTP methods to the native Ingress method-kind enum. */
|
|
68
|
+
export const METHOD_KIND: Record<string, number> = {
|
|
69
|
+
GET: 0,
|
|
70
|
+
HEAD: 1,
|
|
71
|
+
POST: 2,
|
|
72
|
+
PUT: 3,
|
|
73
|
+
PATCH: 4,
|
|
74
|
+
DELETE: 5,
|
|
75
|
+
OPTIONS: 6,
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Method-kind value for methods not present in {@link METHOD_KIND}. */
|
|
79
|
+
export const METHOD_KIND_UNKNOWN = 7
|
|
80
|
+
|
|
81
|
+
/** Which request headers to extract into the packed input. */
|
|
82
|
+
export interface HeaderPlan {
|
|
83
|
+
cookie: boolean
|
|
84
|
+
cors: boolean
|
|
85
|
+
proxy: boolean
|
|
86
|
+
proto: boolean
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Build the `HeaderPlan` for an ingress options object. Shared by BOTH ingress
|
|
91
|
+
* paths (`createIngressFast` and `createIngressHandler`) so the cookie/cors/
|
|
92
|
+
* proxy/proto extraction decisions can never silently diverge between them.
|
|
93
|
+
*
|
|
94
|
+
* Proxy headers (X-Forwarded-For / X-Real-IP) are requested when either
|
|
95
|
+
* `trustProxy` is true or `trustedProxies.enabled` is true — this is driven by
|
|
96
|
+
* the trust configuration alone, NOT by whether rate limiting is enabled.
|
|
97
|
+
* `proto` (https detection) follows the same trust rule and only applies when
|
|
98
|
+
* `https` is not pinned explicitly.
|
|
99
|
+
*/
|
|
100
|
+
export function buildHeaderPlan(options: {
|
|
101
|
+
parseCookies?: boolean
|
|
102
|
+
cors?: unknown
|
|
103
|
+
trustProxy?: boolean
|
|
104
|
+
trustedProxies?: { enabled?: boolean }
|
|
105
|
+
https?: boolean
|
|
106
|
+
}): HeaderPlan {
|
|
107
|
+
const trust = options.trustProxy === true || options.trustedProxies?.enabled === true
|
|
108
|
+
return {
|
|
109
|
+
cookie: options.parseCookies === true,
|
|
110
|
+
cors: options.cors != null,
|
|
111
|
+
proxy: trust,
|
|
112
|
+
proto: trust && options.https === undefined,
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Assert that a handler callback returned a synchronous (non-thenable) value.
|
|
118
|
+
*
|
|
119
|
+
* The ingress result objects are invalidated once `run()` returns, so an async
|
|
120
|
+
* callback would observe a stale/zeroed result. Throw instead of silently
|
|
121
|
+
* returning a broken value. `label` is used in the error message, e.g.
|
|
122
|
+
* `"createIngressFast().run()"`.
|
|
123
|
+
*/
|
|
124
|
+
export function assertSyncCallback<T>(out: T, label: string): void {
|
|
125
|
+
if (
|
|
126
|
+
out !== null &&
|
|
127
|
+
(typeof out === 'object' || typeof out === 'function') &&
|
|
128
|
+
typeof (out as { then?: unknown }).then === 'function'
|
|
129
|
+
) {
|
|
130
|
+
throw new Error(`${label} callback must be synchronous.`)
|
|
131
|
+
}
|
|
132
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// src/ingress/status.ts — HTTP status normalization helpers shared by both
|
|
2
|
+
// ingress paths (the fast path and the pre-baked handler path both normalize
|
|
3
|
+
// the raw status emitted by the native Ingress into a valid response status).
|
|
4
|
+
//
|
|
5
|
+
// These helpers operate on the numeric error-code contract defined in
|
|
6
|
+
// ./constants.ts, so they live in one place and cannot drift.
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
ERR_CODE_NONE,
|
|
10
|
+
ERR_CODE_CORS_PREFLIGHT,
|
|
11
|
+
ERR_CODE_RATE_LIMITED,
|
|
12
|
+
ERR_CODE_BODY_TOO_LARGE,
|
|
13
|
+
ERR_CODE_INVALID_JSON,
|
|
14
|
+
ERR_CODE_SCHEMA_VALIDATION,
|
|
15
|
+
ERR_CODE_BAD_REQUEST,
|
|
16
|
+
ERR_CODE_REQUEST_TOO_LARGE,
|
|
17
|
+
ERR_CODE_REQUEST_TIMEOUT,
|
|
18
|
+
ERR_CODE_INTERNAL,
|
|
19
|
+
} from './constants'
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Whether a raw status is a legal HTTP response status for the ingress
|
|
23
|
+
* pipeline (101 switch-protocol or any 2xx–5xx).
|
|
24
|
+
*/
|
|
25
|
+
export function isValidResponseStatus(status: number): boolean {
|
|
26
|
+
return status === 101 || (status >= 200 && status <= 599)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Map a native ingress error code to the HTTP status that should be returned.
|
|
31
|
+
* A permitted CORS preflight always yields 204.
|
|
32
|
+
*/
|
|
33
|
+
export function statusForErrorCode(errorCode: number, isPreflightAllowed: boolean): number {
|
|
34
|
+
if (isPreflightAllowed) return 204
|
|
35
|
+
|
|
36
|
+
switch (errorCode) {
|
|
37
|
+
case ERR_CODE_CORS_PREFLIGHT:
|
|
38
|
+
return 403
|
|
39
|
+
case ERR_CODE_RATE_LIMITED:
|
|
40
|
+
return 429
|
|
41
|
+
case ERR_CODE_BODY_TOO_LARGE:
|
|
42
|
+
return 413
|
|
43
|
+
case ERR_CODE_INVALID_JSON:
|
|
44
|
+
return 400
|
|
45
|
+
case ERR_CODE_SCHEMA_VALIDATION:
|
|
46
|
+
return 422
|
|
47
|
+
case ERR_CODE_BAD_REQUEST:
|
|
48
|
+
return 400
|
|
49
|
+
case ERR_CODE_REQUEST_TOO_LARGE:
|
|
50
|
+
return 431
|
|
51
|
+
case ERR_CODE_REQUEST_TIMEOUT:
|
|
52
|
+
return 408
|
|
53
|
+
case ERR_CODE_INTERNAL:
|
|
54
|
+
return 500
|
|
55
|
+
default:
|
|
56
|
+
return 500
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Normalize a raw status to a legal response status, falling back to the
|
|
62
|
+
* error-code mapping when the raw status is not legal.
|
|
63
|
+
*/
|
|
64
|
+
export function normalizeResponseStatus(
|
|
65
|
+
status: number,
|
|
66
|
+
errorCode: number,
|
|
67
|
+
isPreflightAllowed: boolean,
|
|
68
|
+
): number {
|
|
69
|
+
if (isValidResponseStatus(status)) {
|
|
70
|
+
return status
|
|
71
|
+
}
|
|
72
|
+
return statusForErrorCode(errorCode, isPreflightAllowed)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Compute a safe terminal status for a result: a permitted CORS preflight
|
|
77
|
+
* yields 204, otherwise the normalized status (or the error-code mapping).
|
|
78
|
+
*/
|
|
79
|
+
export function safeTerminalStatus(r: {
|
|
80
|
+
readonly status: number
|
|
81
|
+
readonly errorCode: number
|
|
82
|
+
readonly isPreflight: boolean
|
|
83
|
+
readonly corsAllowed: boolean
|
|
84
|
+
}): number {
|
|
85
|
+
const preflightAllowed = r.isPreflight && r.corsAllowed
|
|
86
|
+
if (preflightAllowed) return 204
|
|
87
|
+
|
|
88
|
+
const s = normalizeResponseStatus(r.status, r.errorCode, false)
|
|
89
|
+
|
|
90
|
+
if (s >= 400) return s
|
|
91
|
+
|
|
92
|
+
if (r.errorCode !== ERR_CODE_NONE) {
|
|
93
|
+
return statusForErrorCode(r.errorCode, false)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return 500
|
|
97
|
+
}
|