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,475 @@
1
+ // src/ingress/server.ts — Bun.serve builder over pre-baked route handlers.
2
+
3
+ import { isBun } from '../shared/runtime'
4
+ import {
5
+ deleteHandler,
6
+ echoHandler,
7
+ fallbackHandler,
8
+ headHandler,
9
+ jsonWriteHandler,
10
+ optionsHandler,
11
+ readHandler,
12
+ } from './routes'
13
+ import type { BakedHandlerOptions } from './routes/common'
14
+ import { nativeResponderRoute } from './routes/responder'
15
+ import type { NativeResponder, OptimizedIngressHandler, TerminalStyle } from './types'
16
+
17
+ /** Server-level default for the socket request-body cap (16 MiB). */
18
+ export const DEFAULT_MAX_REQUEST_BODY_SIZE = 16 * 1024 * 1024
19
+
20
+ /** A raw web-standard route handler (`Request` → `Response`), e.g. the
21
+ * health/metrics probes (`livenessHandler`, `readinessHandler`,
22
+ * `healthHandler`, `metricsHandler`). Accepted as `BakedRoute.read` and
23
+ * served for GET directly, outside the ingress pipeline. The optional third
24
+ * arg carries the matched dynamic path params (`:param`/`*`) on the Node
25
+ * adapter (Bun's native router handles matching; the ingress pipeline does
26
+ * not echo params in its response). */
27
+ export type RouteHandler = (
28
+ req: Request,
29
+ srv?: unknown,
30
+ params?: Record<string, string>,
31
+ ) => Response | Promise<Response>
32
+
33
+ /** Route spec for the Bun.serve builder. */
34
+ export interface BakedRoute {
35
+ /**
36
+ * Wires GET + HEAD read handlers. Accepts an optimized ingress handler OR a
37
+ * raw `RouteHandler` function (health/metrics probes are raw — see
38
+ * src/ingress/health.ts). A raw handler is served for GET only.
39
+ */
40
+ read?: OptimizedIngressHandler | RouteHandler
41
+ /** Wires POST/PUT/PATCH JSON-write handlers (and OPTIONS fallback). */
42
+ write?: OptimizedIngressHandler
43
+ /** Wires a POST echo handler. */
44
+ echo?: OptimizedIngressHandler
45
+ /** Wires a GET read handler (cookies-style route). */
46
+ cookies?: OptimizedIngressHandler
47
+ /** Wires a DELETE read-style handler. */
48
+ delete?: OptimizedIngressHandler
49
+ /**
50
+ * A JS responder route: the native pipeline owns parse/validate/CORS/
51
+ * rate-limit + terminal rejections; the responder builds the 2xx body
52
+ * (async OK). Wired for `methods` (default GET). Built by
53
+ * `nativeResponderRoute` (src/ingress/routes/responder.ts).
54
+ */
55
+ responder?: {
56
+ /** The route's compiled native `IngressInner`. */
57
+ ingress: OptimizedIngressHandler
58
+ /** The JS 2xx builder. */
59
+ handler: NativeResponder
60
+ /** HTTP methods to wire (default `['GET']`). */
61
+ methods?: ReadonlyArray<string>
62
+ /** Terminal envelope style (`'castrum'` default / `'ignex'`). */
63
+ terminalStyle?: TerminalStyle
64
+ /** Read the body for native validation (default false — framework owns it). */
65
+ readBody?: boolean
66
+ }
67
+ /** Overrides `maxBodyBytes` for this route's write/echo handlers. */
68
+ maxBodyBytes?: number
69
+ /**
70
+ * Overall deadline (ms) for reading the request body on this route's
71
+ * write/echo handlers. Default: 30_000 (`DEFAULT_BODY_TIMEOUT_MS`).
72
+ * Set 0 to disable.
73
+ */
74
+ bodyTimeoutMs?: number
75
+ }
76
+
77
+ /** RFC 6455 handshake values returned by `CreateIngressServerOptions.upgrade`. */
78
+ export interface NodeUpgradeHandshake {
79
+ /** `Sec-WebSocket-Accept` value (compute via `rust.text.wsAcceptKey`). */
80
+ accept: string
81
+ /** Optional negotiated subprotocol (`Sec-WebSocket-Protocol`). */
82
+ protocol?: string
83
+ }
84
+
85
+ /** Options for {@link createIngressServer}. */
86
+ export interface CreateIngressServerOptions {
87
+ port: number
88
+ hostname?: string
89
+ idleTimeout?: number
90
+ maxRequestBodySize?: number
91
+ reusePort?: boolean
92
+ copyBody?: boolean
93
+ getIp?: (req: Request, srv: unknown) => string | undefined
94
+ routes: Record<string, BakedRoute>
95
+ fallback?: OptimizedIngressHandler
96
+ /**
97
+ * Invoked when an unhandled error escapes a request handler (currently wired
98
+ * on the Node adapter; the Bun path uses runtime.onResponse). Never throws —
99
+ * hook failures are swallowed.
100
+ */
101
+ onError?: (info: { error: Error; request?: Request }) => void
102
+ /** Node adapter only: ms to receive the complete request (headers + body).
103
+ * Guards slowloris/trickling requests. Default: 30_000. */
104
+ requestTimeoutMs?: number
105
+ /** Node adapter only: ms to receive the request headers. Default: 30_000. */
106
+ headersTimeoutMs?: number
107
+ /** Node adapter only: max requests per keep-alive socket before Node forces
108
+ * a fresh connection (bounds long-lived socket reuse). Default: 1000. */
109
+ maxRequestsPerSocket?: number
110
+ /** Node adapter only: optional WebSocket upgrade handler. Return the RFC 6455
111
+ * handshake values (see {@link NodeUpgradeHandshake}) to complete the
112
+ * handshake on the hijacked socket; return null/undefined to decline the
113
+ * upgrade. Ignored by the Bun server. (`createWebSocketUpgrade` is Bun-only
114
+ * — Node's Response cannot carry a 101.) */
115
+ upgrade?: (req: Request) => NodeUpgradeHandshake | null | undefined
116
+ /** Node adapter only: called after a WebSocket upgrade handshake completes
117
+ * (the 101 response was accepted), with the hijacked socket so the caller
118
+ * can attach a frame codec/message loop (e.g. the `ws` library). Ignored by
119
+ * the Bun server. */
120
+ onUpgrade?: (socket: import('node:stream').Duplex, req: unknown, head: Buffer) => void
121
+ }
122
+
123
+ /**
124
+ * Minimal surface of the running server that castrum relies on.
125
+ *
126
+ * This is intentionally NOT `ReturnType<typeof Bun.serve>` so that the public
127
+ * types stay runtime-agnostic — Node.js TypeScript consumers don't need
128
+ * `@types/bun` just to import castrum's types. Bun's server exposes far more
129
+ * fields; treat the handle as opaque.
130
+ */
131
+ export interface ServerHandle {
132
+ /** The port the server is bound to, when known (Bun exposes it; Node's
133
+ * http.Server does not — use `BakedServer.port` instead). */
134
+ port?: number
135
+ /** Force-stop the server. Bun semantics: `true` drops active connections. */
136
+ stop(force?: boolean): void
137
+ }
138
+
139
+ /** A running server instance plus a stop helper. */
140
+ export interface BakedServer {
141
+ server: ServerHandle
142
+ stop(): void
143
+ port: number
144
+ }
145
+
146
+ /** Options for `gracefulShutdown` (drain grace period + signals). */
147
+ export interface GracefulShutdownOptions {
148
+ /** Grace period (ms) to drain in-flight requests before force-closing. */
149
+ timeoutMs?: number
150
+ /** Signals to listen for. Default: SIGTERM + SIGINT. */
151
+ signals?: NodeJS.Signals[]
152
+ }
153
+
154
+ /**
155
+ * Wire SIGTERM/SIGINT to a graceful shutdown: soft-stop (drain in-flight
156
+ * requests) then force-close after `timeoutMs`. Works with both the Bun and
157
+ * Node server handles (both expose `stop(force?)`).
158
+ *
159
+ * Returns a cleanup function that removes the signal listeners. Call it (or
160
+ * `process.exit`) to tear down without hanging.
161
+ */
162
+ export function gracefulShutdown(
163
+ handles: ReadonlyArray<ServerHandle>,
164
+ options: GracefulShutdownOptions = {},
165
+ ): () => void {
166
+ const timeoutMs = options.timeoutMs ?? 5_000
167
+ const signals = options.signals ?? ['SIGTERM', 'SIGINT']
168
+
169
+ let shuttingDown = false
170
+ const handler = () => {
171
+ if (shuttingDown) return
172
+ shuttingDown = true
173
+ // Soft stop: stop accepting new work and drain in-flight requests.
174
+ for (const h of handles) {
175
+ try {
176
+ h.stop(false)
177
+ } catch {
178
+ // ignore already-stopped handles
179
+ }
180
+ }
181
+ // Force-close after the grace period.
182
+ const timer = setTimeout(() => {
183
+ for (const h of handles) {
184
+ try {
185
+ h.stop(true)
186
+ } catch {
187
+ // ignore
188
+ }
189
+ }
190
+ }, timeoutMs)
191
+ timer.unref?.()
192
+ }
193
+
194
+ for (const sig of signals) {
195
+ process.on(sig, handler)
196
+ }
197
+
198
+ return () => {
199
+ for (const sig of signals) {
200
+ process.removeListener(sig, handler)
201
+ }
202
+ }
203
+ }
204
+
205
+ /** Options `buildRouteHandlers` actually consumes (a subset of the server
206
+ * options — it does not need `port` or the Node-only server fields). */
207
+ export type BuildRouteHandlersOptions = Pick<
208
+ CreateIngressServerOptions,
209
+ 'routes' | 'fallback' | 'getIp' | 'copyBody'
210
+ >
211
+
212
+ /**
213
+ * Build the route → { method → handler } map from a server spec.
214
+ *
215
+ * Shared by `createIngressServer` (Bun.serve) and `createIngressServerNode`
216
+ * (node:http) so both runtimes wire the exact same route handlers.
217
+ */
218
+ export function buildRouteHandlers(options: BuildRouteHandlersOptions): {
219
+ routes: Record<string, Record<string, unknown>>
220
+ baseOpts: BakedHandlerOptions
221
+ } {
222
+ const baseOpts: BakedHandlerOptions = {
223
+ getIp: options.getIp,
224
+ copyBody: options.copyBody,
225
+ }
226
+
227
+ const serverRoutes: Record<string, Record<string, unknown>> = {}
228
+
229
+ for (const [path, spec] of Object.entries(options.routes)) {
230
+ const routeOpts: BakedHandlerOptions = {
231
+ ...baseOpts,
232
+ maxBodyBytes: spec.maxBodyBytes,
233
+ bodyTimeoutMs: spec.bodyTimeoutMs,
234
+ }
235
+ const methods: Record<string, unknown> = {}
236
+
237
+ if (spec.read) {
238
+ if (typeof spec.read === 'function') {
239
+ // Raw request→Response handler (probes, /metrics): serve GET directly,
240
+ // outside the ingress pipeline. HEAD is intentionally not wired (a raw
241
+ // handler returns a body; probes use GET).
242
+ methods.GET = spec.read
243
+ } else {
244
+ methods.GET = readHandler(spec.read, routeOpts)
245
+ methods.HEAD = headHandler(spec.read, routeOpts)
246
+ }
247
+ }
248
+
249
+ if (spec.write) {
250
+ const writeOpts: BakedHandlerOptions = {
251
+ ...routeOpts,
252
+ fallback: options.fallback ?? spec.write,
253
+ }
254
+ methods.POST = jsonWriteHandler(spec.write, writeOpts)
255
+ methods.PUT = jsonWriteHandler(spec.write, writeOpts)
256
+ methods.PATCH = jsonWriteHandler(spec.write, writeOpts)
257
+ }
258
+
259
+ if (spec.echo) {
260
+ methods.POST = echoHandler(spec.echo, routeOpts)
261
+ }
262
+
263
+ if (spec.cookies) {
264
+ methods.GET = readHandler(spec.cookies, routeOpts)
265
+ }
266
+
267
+ if (spec.delete) {
268
+ methods.DELETE = deleteHandler(spec.delete, routeOpts)
269
+ }
270
+
271
+ if (spec.responder) {
272
+ // JS responder route: native decides + rejects; JS builds the 2xx.
273
+ const responderRoute = nativeResponderRoute(spec.responder.ingress, spec.responder.handler, {
274
+ ...routeOpts,
275
+ terminalStyle: spec.responder.terminalStyle,
276
+ readBody: spec.responder.readBody,
277
+ })
278
+ const responderMethods = spec.responder.methods ?? ['GET']
279
+ for (const m of responderMethods) {
280
+ methods[m] = responderRoute
281
+ }
282
+ }
283
+
284
+ // CORS preflight (OPTIONS) is served for EVERY route with a NATIVE handler
285
+ // (not just write routes with a fallback), so read-only routes also answer
286
+ // preflights with 204/403 from the native pipeline. Raw probe handlers
287
+ // (plain functions) are excluded — browsers don't preflight probes.
288
+ const primary =
289
+ spec.delete ??
290
+ (spec.read && typeof spec.read !== 'function' ? spec.read : undefined) ??
291
+ spec.cookies ??
292
+ spec.write ??
293
+ spec.echo
294
+ if (primary) {
295
+ methods.OPTIONS = optionsHandler(primary, routeOpts)
296
+ }
297
+
298
+ serverRoutes[path] = methods
299
+ }
300
+
301
+ return { routes: serverRoutes, baseOpts }
302
+ }
303
+
304
+ /** A matched dynamic route: the handler map + the extracted path params. */
305
+ export interface PathMatch {
306
+ methods: Record<string, unknown>
307
+ params: Record<string, string>
308
+ }
309
+
310
+ interface CompiledDynamicPattern {
311
+ re: RegExp
312
+ paramNames: string[]
313
+ rest: boolean
314
+ methods: Record<string, unknown>
315
+ /** Number of literal (static) segments — used to prefer the most specific pattern. */
316
+ staticSegments: number
317
+ }
318
+
319
+ /**
320
+ * Build a path matcher over a route map, supporting `:param` and `*` (rest)
321
+ * segments. Exact (static) paths always win over dynamic patterns; dynamic
322
+ * patterns are ordered most-specific-first (most static segments, then fewest
323
+ * params). Used by `createIngressServerNode` (Bun's native router already
324
+ * matches dynamic patterns itself). The ingress pipeline does not echo path
325
+ * params in its response, so matching only selects the handler.
326
+ */
327
+ export function buildPathMatcher(
328
+ routes: Record<string, Record<string, unknown>>,
329
+ ): (pathname: string) => PathMatch | undefined {
330
+ const exact = new Map<string, Record<string, unknown>>()
331
+ const dynamic: CompiledDynamicPattern[] = []
332
+
333
+ for (const [path, methods] of Object.entries(routes)) {
334
+ const hasParam = path.indexOf(':') !== -1
335
+ const hasRest = path.indexOf('*') !== -1
336
+ if (!hasParam && !hasRest) {
337
+ exact.set(path, methods)
338
+ continue
339
+ }
340
+
341
+ const paramNames: string[] = []
342
+ let rest = false
343
+ let staticSegments = 0
344
+ const parts = path.split('/').filter((s) => s.length > 0)
345
+ const source = parts
346
+ .map((seg) => {
347
+ if (seg === '*') {
348
+ rest = true
349
+ return '(.*)'
350
+ }
351
+ if (seg.startsWith(':')) {
352
+ paramNames.push(seg.slice(1))
353
+ return '([^/]+)'
354
+ }
355
+ staticSegments++
356
+ return seg.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
357
+ })
358
+ .join('/')
359
+
360
+ dynamic.push({
361
+ re: new RegExp(`^/${source}${rest ? '' : '/?$'}`),
362
+ paramNames,
363
+ rest,
364
+ methods,
365
+ staticSegments,
366
+ })
367
+ }
368
+
369
+ dynamic.sort(
370
+ (a, b) => b.staticSegments - a.staticSegments || a.paramNames.length - b.paramNames.length,
371
+ )
372
+
373
+ return (pathname: string) => {
374
+ const exactHit = exact.get(pathname)
375
+ if (exactHit !== undefined) {
376
+ return { methods: exactHit, params: {} }
377
+ }
378
+ for (const d of dynamic) {
379
+ const m = d.re.exec(pathname)
380
+ if (m !== null) {
381
+ const params: Record<string, string> = {}
382
+ for (let i = 0; i < d.paramNames.length; i++) {
383
+ const raw = m[i + 1]
384
+ if (raw !== undefined) params[d.paramNames[i] ?? ''] = safeDecode(raw)
385
+ }
386
+ if (d.rest) {
387
+ const restRaw = m[d.paramNames.length + 1]
388
+ if (restRaw !== undefined) params['*'] = safeDecode(restRaw)
389
+ }
390
+ return { methods: d.methods, params }
391
+ }
392
+ }
393
+ return undefined
394
+ }
395
+ }
396
+
397
+ function safeDecode(value: string): string {
398
+ try {
399
+ return decodeURIComponent(value)
400
+ } catch {
401
+ return value
402
+ }
403
+ }
404
+
405
+ /** Typed options forwarded to `Bun.serve`. */
406
+ interface BunServerOptions {
407
+ hostname: string
408
+ port: number
409
+ idleTimeout: number
410
+ routes: Record<string, Record<string, unknown>>
411
+ maxRequestBodySize: number
412
+ reusePort?: boolean
413
+ fetch?: (req: Request) => Response | Promise<Response>
414
+ }
415
+
416
+ /** Build a Bun.serve config from pre-baked route handlers. */
417
+ export function createIngressServer(options: CreateIngressServerOptions): BakedServer {
418
+ // Bun-only by design: this builder targets Bun.serve's Routes API. Node
419
+ // consumers use `createIngressServerNode` with the same route handlers
420
+ // (sharing `buildRouteHandlers`).
421
+ if (!isBun()) {
422
+ throw new TypeError(
423
+ 'createIngressServer is Bun-only (it builds a Bun.serve config). ' +
424
+ 'On Node.js use createIngressServerNode with the same route handlers.',
425
+ )
426
+ }
427
+ const { routes: serverRoutes, baseOpts } = buildRouteHandlers(options)
428
+
429
+ const serverOptions: BunServerOptions = {
430
+ hostname: options.hostname ?? '0.0.0.0',
431
+ port: options.port,
432
+ idleTimeout: options.idleTimeout ?? 30,
433
+ routes: serverRoutes,
434
+ // Socket-level request-size guard. Bun's default is ~128 MiB; we default
435
+ // to 16 MiB so an oversized request is rejected at the socket without ever
436
+ // being buffered (route handlers enforce the tighter `maxBodyBytes`).
437
+ maxRequestBodySize: options.maxRequestBodySize ?? DEFAULT_MAX_REQUEST_BODY_SIZE,
438
+ }
439
+ if (options.reusePort) {
440
+ serverOptions.reusePort = true
441
+ }
442
+ if (options.fallback) {
443
+ serverOptions.fetch = fallbackHandler(options.fallback, baseOpts)
444
+ }
445
+
446
+ // Narrow bridge to Bun's (version-dependent) Serve type — Bun's Routes /
447
+ // FetchHandler shapes are complex, so we cast once instead of `as any`.
448
+ let server: ReturnType<typeof Bun.serve>
449
+ const init = serverOptions as Parameters<typeof Bun.serve>[0]
450
+ try {
451
+ server = Bun.serve(init)
452
+ } catch (err) {
453
+ if (options.reusePort) {
454
+ delete serverOptions.reusePort
455
+ server = Bun.serve(serverOptions as Parameters<typeof Bun.serve>[0])
456
+ } else {
457
+ throw err
458
+ }
459
+ }
460
+
461
+ return {
462
+ server,
463
+ stop: () => {
464
+ try {
465
+ server.stop(true)
466
+ } catch {
467
+ // already stopped
468
+ }
469
+ },
470
+ // The ACTUAL bound port (not `options.port`): with `port: 0` Bun
471
+ // auto-assigns a free port, and consumers expect the real listening port
472
+ // (parity with the Node adapter's `ready`/port behavior).
473
+ port: typeof server.port === 'number' ? server.port : options.port,
474
+ }
475
+ }
@@ -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
+ }