castrum 0.9.7 → 0.9.9

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 (108) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/castrum.darwin-arm64.node +0 -0
  4. package/castrum.darwin-x64.node +0 -0
  5. package/castrum.linux-arm64-gnu.node +0 -0
  6. package/castrum.linux-arm64-musl.node +0 -0
  7. package/castrum.linux-x64-gnu.node +0 -0
  8. package/castrum.linux-x64-musl.node +0 -0
  9. package/castrum.linux-x64-v3-gnu.node +0 -0
  10. package/castrum.win32-x64-msvc.node +0 -0
  11. package/dist/index.d.ts +664 -8
  12. package/dist/index.js +2014 -1327
  13. package/docs/BENCHMARKS.md +13 -0
  14. package/docs/CASE_STUDY.md +1 -1
  15. package/docs/ENVIRONMENT.md +8 -1
  16. package/docs/FFI_BUN_GUIDE.md +1 -1
  17. package/docs/INGRESS.md +4 -4
  18. package/docs/REPO_MAP.md +7 -5
  19. package/docs/RND-CONCURRENCY.md +20 -4
  20. package/docs/superpowers/plans/2026-09-18-castrum-hardening-cleanup.md +617 -0
  21. package/docs/superpowers/plans/2026-09-18-elysia-learnings-adoption.md +634 -0
  22. package/index.ts +6 -0
  23. package/package.json +4 -2
  24. package/rust/crypto/aead.rs +44 -0
  25. package/rust/crypto/argon2.rs +32 -2
  26. package/rust/crypto/jwt/tests.rs +19 -0
  27. package/rust/crypto/jwt/token.rs +43 -28
  28. package/rust/crypto/session.rs +6 -0
  29. package/rust/ffi/crypto.rs +63 -32
  30. package/rust/ffi/hashing.rs +48 -33
  31. package/rust/ffi/http.rs +62 -36
  32. package/rust/ffi/metrics.rs +6 -2
  33. package/rust/ffi/payload.rs +47 -27
  34. package/rust/ffi/task.rs +55 -35
  35. package/rust/ffi/tests.rs +24 -0
  36. package/rust/ffi/util.rs +40 -0
  37. package/rust/http/headers.rs +13 -0
  38. package/rust/http/multipart.rs +139 -0
  39. package/rust/ingress/api.rs +21 -5
  40. package/rust/ingress/mod.rs +7 -0
  41. package/rust/ingress/native_route.rs +25 -5
  42. package/rust/ingress/schema_cache.rs +266 -0
  43. package/rust/task/completion.rs +7 -0
  44. package/rust/task/mod.rs +2 -1
  45. package/rust/task/runtime.rs +55 -7
  46. package/rust/task/tests.rs +128 -1
  47. package/src/ingress/abort.ts +48 -0
  48. package/src/ingress/body.ts +104 -8
  49. package/src/ingress/decode/baked-result.ts +13 -5
  50. package/src/ingress/decode/fast-result.ts +1 -1
  51. package/src/ingress/decode/result-base.ts +16 -2
  52. package/src/ingress/handlers.ts +104 -31
  53. package/src/ingress/headers/memoized-headers.ts +40 -0
  54. package/src/ingress/health.ts +22 -0
  55. package/src/ingress/index.ts +3 -1
  56. package/src/ingress/native-route.ts +25 -6
  57. package/src/ingress/options.ts +26 -2
  58. package/src/ingress/packing/gather-raw-headers.ts +24 -8
  59. package/src/ingress/response/baked-response.ts +118 -28
  60. package/src/ingress/router.ts +50 -3
  61. package/src/ingress/routes/common.ts +35 -11
  62. package/src/ingress/routes/echo.ts +60 -7
  63. package/src/ingress/routes/json-write.ts +32 -8
  64. package/src/ingress/routes/native.ts +24 -5
  65. package/src/ingress/routes/options.ts +6 -2
  66. package/src/ingress/routes/responder.ts +29 -9
  67. package/src/ingress/routes/safe-record.ts +57 -0
  68. package/src/ingress/server-error.ts +111 -0
  69. package/src/ingress/server-node.ts +135 -37
  70. package/src/ingress/server.ts +143 -36
  71. package/src/ingress/shared.ts +1 -1
  72. package/src/ingress/types.ts +15 -0
  73. package/src/integration/pipeline.ts +6 -0
  74. package/src/loader/ops.ts +1 -1
  75. package/src/native/ffi/build/codecs-selftest.ts +305 -0
  76. package/src/native/ffi/build/codecs.ts +0 -296
  77. package/src/native/ffi/build/instances-selftest.ts +264 -0
  78. package/src/native/ffi/build/instances.ts +10 -259
  79. package/src/native/ffi/build/parse-selftest.ts +238 -0
  80. package/src/native/ffi/build/parse.ts +11 -234
  81. package/src/native/ffi/build/task.ts +2 -1
  82. package/src/native/ffi/build.ts +5 -1
  83. package/src/native/ffi/selftest.ts +3 -3
  84. package/src/native/ffi/symbols.ts +460 -0
  85. package/src/native/ffi/types.ts +6 -1
  86. package/src/native/ffi.ts +2 -434
  87. package/src/native/ingress-binding.ts +39 -0
  88. package/src/native/types.ts +6 -0
  89. package/src/runtime/codec.ts +24 -0
  90. package/src/runtime/detect.ts +14 -0
  91. package/src/runtime/types.ts +11 -0
  92. package/src/rust-ffi/batch/build.ts +18 -8
  93. package/src/rust-ffi/batch/types.ts +15 -0
  94. package/src/rust-ffi/context.ts +46 -3
  95. package/src/rust-ffi/options.ts +19 -0
  96. package/src/rust-ffi/scalar/crypto.ts +8 -0
  97. package/src/rust-ffi/scalar/factories.ts +53 -20
  98. package/src/rust-ffi/scalar/http.ts +60 -10
  99. package/src/rust-ffi/scalar/interface.ts +18 -1
  100. package/src/rust-ffi/scalar/payload.ts +27 -1
  101. package/src/rust-ffi/text.ts +5 -4
  102. package/src/shared/buffer-pool.ts +54 -9
  103. package/src/shared/codec.ts +26 -0
  104. package/src/shared/memory.ts +68 -0
  105. package/src/shared/metrics.ts +86 -3
  106. package/src/shared/packed/parsers.ts +64 -7
  107. package/src/shared/runtime.ts +7 -0
  108. package/src/task/runtime.ts +81 -37
@@ -22,6 +22,7 @@ import {
22
22
  import { type Duplex, Readable } from 'node:stream'
23
23
  import { buildPathMatcher } from './path-matcher'
24
24
  import { fallbackHandler } from './routes'
25
+ import { createServerErrorHandler } from './server-error'
25
26
  import {
26
27
  type BakedServer,
27
28
  buildRouteHandlers,
@@ -52,8 +53,20 @@ export interface NodeIngressServer extends BakedServer {
52
53
  ready: Promise<number>
53
54
  }
54
55
 
55
- /** Convert a Node IncomingMessage into a web-standard Request. */
56
- function nodeRequestToWebRequest(req: IncomingMessage): Request {
56
+ /**
57
+ * Convert a Node IncomingMessage into a web-standard `Request`.
58
+ *
59
+ * Node's `IncomingMessage` has no web `AbortSignal`, so this bridges one to
60
+ * `Request.signal`: the request aborts when the client disconnects — on the
61
+ * explicit `'aborted'` event, or on `'close'` while the request has NOT
62
+ * completed (`close` after a fully-received request is normal socket
63
+ * teardown and must not abort). Exported so the adapter's signal wiring is
64
+ * unit-testable without a socket.
65
+ *
66
+ * @param req - The Node incoming request.
67
+ * @returns A web `Request` whose `signal` tracks client disconnect.
68
+ */
69
+ export function nodeRequestToWebRequest(req: IncomingMessage): Request {
57
70
  const host = req.headers.host ?? 'localhost'
58
71
  const url = new URL(req.url ?? '/', `http://${host}`)
59
72
 
@@ -73,11 +86,20 @@ function nodeRequestToWebRequest(req: IncomingMessage): Request {
73
86
  // request actually carries one (content-length > 0 or chunked), so DELETE/
74
87
  // OPTIONS-with-a-body are no longer dropped.
75
88
  const bodyForbidden = method === 'GET' || method === 'HEAD'
76
- const hasBody =
77
- Number(req.headers['content-length'] ?? 0) > 0 || req.headers['transfer-encoding'] !== undefined
89
+ const hasBody = requestHasBody(req)
78
90
 
79
91
  const init: RequestInit & { duplex?: 'half' } = { method, headers }
80
92
 
93
+ // Client-disconnect bridge (Node-only code — the Bun path uses Bun's native
94
+ // `Request.signal`). `abort()` is idempotent, so the two listeners may both
95
+ // fire on a premature close.
96
+ const abort = new AbortController()
97
+ req.on('aborted', () => abort.abort())
98
+ req.on('close', () => {
99
+ if (!req.complete) abort.abort()
100
+ })
101
+ init.signal = abort.signal
102
+
81
103
  // Stream the request body through (the route handlers call
82
104
  // `req.arrayBuffer()` which drains this stream).
83
105
  if (hasBody && !bodyForbidden) {
@@ -143,7 +165,7 @@ async function writeResponse(res: ServerResponse, response: Response): Promise<v
143
165
  */
144
166
  function drainUnreadBody(res: ServerResponse): void {
145
167
  const req = res.req
146
- if (req !== undefined && req !== null && !req.complete && hasBody(req)) {
168
+ if (req !== undefined && req !== null && !req.complete && requestHasBody(req)) {
147
169
  req.resume()
148
170
  // Only force-close when the request actually carried a body that was not
149
171
  // consumed. A bodiless GET/HEAD is always safe to keep alive (bun's
@@ -156,12 +178,60 @@ function drainUnreadBody(res: ServerResponse): void {
156
178
  }
157
179
 
158
180
  /** Whether an incoming request carried a body (content-length or chunked). */
159
- function hasBody(req: IncomingMessage): boolean {
181
+ function requestHasBody(req: IncomingMessage): boolean {
160
182
  return (
161
183
  Number(req.headers['content-length'] ?? 0) > 0 || req.headers['transfer-encoding'] !== undefined
162
184
  )
163
185
  }
164
186
 
187
+ /**
188
+ * Cached body/status/headers for a bare static `Response` route value.
189
+ *
190
+ * A web `Response` body is ONE-SHOT: writing it once consumes the stream.
191
+ * `Bun.serve` snapshots a bare `Response` into its native table, but the
192
+ * node:http adapter must materialize a FRESH `Response` per request, so the
193
+ * body is read once and the bytes cached. The WeakMap key is the original
194
+ * `Response` instance, so the cache never retains anything the route table
195
+ * does not already hold. The cached value is the in-flight PROMISE (not the
196
+ * resolved bytes) so concurrent first requests share ONE body read instead of
197
+ * racing to consume the same one-shot stream.
198
+ */
199
+ const STATIC_RESPONSE_CACHE = new WeakMap<
200
+ Response,
201
+ Promise<{ status: number; statusText: string; headers: Headers; body: Uint8Array | null }>
202
+ >()
203
+
204
+ /** Materialize a fresh, unconsumed `Response` from a static route value. */
205
+ function materializeStaticResponse(response: Response): Promise<Response> {
206
+ let cached = STATIC_RESPONSE_CACHE.get(response)
207
+ if (cached === undefined) {
208
+ cached = (async () => {
209
+ const body = response.body === null ? null : new Uint8Array(await response.arrayBuffer())
210
+ const headers = new Headers(response.headers)
211
+ // Framing headers describe the ORIGINAL stream; drop them so the runtime
212
+ // recomputes them from the re-materialized body (avoiding a stale
213
+ // `content-length` on every replay).
214
+ headers.delete('content-length')
215
+ headers.delete('transfer-encoding')
216
+ return {
217
+ status: response.status,
218
+ statusText: response.statusText,
219
+ headers,
220
+ body,
221
+ }
222
+ })()
223
+ STATIC_RESPONSE_CACHE.set(response, cached)
224
+ }
225
+ return cached.then(
226
+ (c) =>
227
+ new Response(c.body, {
228
+ status: c.status,
229
+ statusText: c.statusText,
230
+ headers: new Headers(c.headers),
231
+ }),
232
+ )
233
+ }
234
+
165
235
  /** Build a request listener that dispatches to the shared route map. */
166
236
  function makeRequestListener(
167
237
  options: CreateIngressServerOptions,
@@ -170,6 +240,11 @@ function makeRequestListener(
170
240
  ): (req: IncomingMessage, res: ServerResponse) => Promise<void> {
171
241
  // Exact paths win; `:param`/`*` patterns are matched most-specific-first.
172
242
  const matchPath = buildPathMatcher(routes)
243
+ // Shared masked-500 responder: same body + `onError` hook as the Bun path.
244
+ const onServerError = createServerErrorHandler({
245
+ onError: options.onError,
246
+ logger: options.logger,
247
+ })
173
248
 
174
249
  return async (req, res) => {
175
250
  try {
@@ -202,14 +277,27 @@ function makeRequestListener(
202
277
  const method = webReq.method ?? 'GET'
203
278
 
204
279
  const matched = matchPath(pathname)
205
- const handler = matched?.methods?.[method] as RouteHandler | undefined
280
+ const entry = matched?.methods?.[method]
206
281
 
207
282
  let response: Response
208
- if (handler !== undefined && matched !== undefined) {
283
+ if (entry !== undefined && matched !== undefined) {
209
284
  // The ingress pipeline does not echo path params, so matching only
210
285
  // selects the handler; the extracted params are passed as a 3rd arg
211
286
  // (ignored by current handlers) for future/raw-handler use.
212
- response = await handler(webReq, req, matched.params)
287
+ if (typeof entry === 'function') {
288
+ response = await (entry as RouteHandler)(webReq, req, matched.params)
289
+ } else if (entry instanceof Response) {
290
+ // Bare static route value (Bun's native-table path). Node cannot
291
+ // replay a consumed body, so build a fresh Response per request.
292
+ // `instanceof Response` assumes the value was built with the global
293
+ // `Response` constructor (the one this adapter also uses).
294
+ response = await materializeStaticResponse(entry)
295
+ } else if (options.fallback !== undefined) {
296
+ const fallback = fallbackHandler(options.fallback, baseOpts)
297
+ response = await fallback(webReq, req)
298
+ } else {
299
+ response = new Response('Not Found', { status: 404 })
300
+ }
213
301
  } else if (options.fallback !== undefined) {
214
302
  const fallback = fallbackHandler(options.fallback, baseOpts)
215
303
  response = await fallback(webReq, req)
@@ -219,28 +307,18 @@ function makeRequestListener(
219
307
 
220
308
  await writeResponse(res, response)
221
309
  } catch (err) {
222
- // Never leave a client hanging on a handler failure.
310
+ // Never leave a client hanging on a handler failure. Reuse the shared
311
+ // masked-500 responder so both runtimes emit the same body and hook.
223
312
  if (!res.headersSent) {
224
- res.statusCode = 500
225
- res.setHeader('content-type', 'application/json')
226
- res.end(
227
- JSON.stringify({
228
- error: { code: 'internal_error', message: 'Internal Server Error' },
229
- }),
230
- )
313
+ await writeResponse(res, onServerError(err))
231
314
  } else {
315
+ // Headers already flushed (e.g. a body-stream error mid-write): the
316
+ // masked 500 cannot be delivered, but the onError/logger hooks MUST
317
+ // still observe the failure before the socket is dropped. The returned
318
+ // Response is intentionally discarded.
319
+ onServerError(err)
232
320
  res.destroy()
233
321
  }
234
- // NOTE: surfaced via the onError hook (observability phase).
235
- if (options.onError) {
236
- try {
237
- options.onError({
238
- error: err instanceof Error ? err : new Error(String(err)),
239
- })
240
- } catch {
241
- // hook must never crash the server
242
- }
243
- }
244
322
  }
245
323
  }
246
324
  }
@@ -254,6 +332,35 @@ function makeRequestListener(
254
332
  * (which owns the frame codec — e.g. the `ws` library). Previously a 101 was
255
333
  * written as a normal response and the connection never became a socket.
256
334
  */
335
+ /** RFC 6455 subprotocol is an HTTP token (no separators / control bytes). */
336
+ const WS_PROTOCOL_TOKEN = /^[A-Za-z0-9!#$%&'*+\-.^_`|~]+$/
337
+
338
+ /**
339
+ * Build the RFC 6455 `101 Switching Protocols` response head for a hijacked
340
+ * socket. `protocol` is emitted only when it is a valid HTTP token, so a caller
341
+ * that reflects an untrusted `Sec-WebSocket-Protocol` cannot inject CRLF or
342
+ * extra headers.
343
+ *
344
+ * @param accept - The `Sec-WebSocket-Accept` value (must contain no CR/LF).
345
+ * @param protocol - Optional negotiated subprotocol.
346
+ * @returns The raw response head, terminated by a blank line.
347
+ * @throws If `accept` contains a CR/LF.
348
+ */
349
+ export function formatWebSocketHandshake(accept: string, protocol?: string | null): string {
350
+ if (/[\r\n]/.test(accept)) {
351
+ throw new Error('invalid Sec-WebSocket-Accept value')
352
+ }
353
+ let raw =
354
+ 'HTTP/1.1 101 Switching Protocols\r\n' +
355
+ 'Upgrade: websocket\r\n' +
356
+ 'Connection: Upgrade\r\n' +
357
+ `Sec-WebSocket-Accept: ${accept}\r\n`
358
+ if (protocol && WS_PROTOCOL_TOKEN.test(protocol)) {
359
+ raw += `Sec-WebSocket-Protocol: ${protocol}\r\n`
360
+ }
361
+ return `${raw}\r\n`
362
+ }
363
+
257
364
  function makeUpgradeListener(
258
365
  options: CreateIngressServerOptions,
259
366
  ): (req: IncomingMessage, socket: Duplex, head: Buffer) => Promise<void> {
@@ -268,16 +375,7 @@ function makeUpgradeListener(
268
375
  }
269
376
 
270
377
  // Complete the RFC 6455 handshake on the hijacked socket.
271
- let raw =
272
- 'HTTP/1.1 101 Switching Protocols\r\n' +
273
- 'Upgrade: websocket\r\n' +
274
- 'Connection: Upgrade\r\n' +
275
- `Sec-WebSocket-Accept: ${handshake.accept}\r\n`
276
- if (handshake.protocol) {
277
- raw += `Sec-WebSocket-Protocol: ${handshake.protocol}\r\n`
278
- }
279
- raw += '\r\n'
280
- socket.write(raw)
378
+ socket.write(formatWebSocketHandshake(handshake.accept, handshake.protocol))
281
379
 
282
380
  // Hand the upgraded socket to the caller (frame codec / message loop).
283
381
  options.onUpgrade?.(socket, req, head)
@@ -15,6 +15,7 @@ import {
15
15
  import type { BakedHandlerOptions } from './routes/common'
16
16
  import { nativeRouteHandler } from './routes/native'
17
17
  import { nativeResponderRoute } from './routes/responder'
18
+ import { createServerErrorHandler, guardRouteHandler } from './server-error'
18
19
  import type { NativeResponder, OptimizedIngressHandler, TerminalStyle } from './types'
19
20
 
20
21
  /** Server-level default for the socket request-body cap (16 MiB). */
@@ -33,8 +34,46 @@ export type RouteHandler = (
33
34
  params?: Record<string, string>,
34
35
  ) => Response | Promise<Response>
35
36
 
37
+ /**
38
+ * A prebuilt constant response for static route promotion.
39
+ *
40
+ * Either a bare `Response` or a factory that builds a FRESH `Response` per
41
+ * request. Behavior differs by runtime:
42
+ *
43
+ * - **Bun**: a bare `Response` is snapshotted into `Bun.serve`'s native route
44
+ * table at bind time (verified on Bun 1.4.2 — the body is NOT consumed by the
45
+ * first request) and served on every request without entering JS.
46
+ * - **Node** (`createIngressServerNode`): the adapter reads the bare `Response`
47
+ * body ONCE and materializes a fresh `Response` per request (a web body is
48
+ * one-shot).
49
+ *
50
+ * A factory is invoked by the runtime on every request, so it MUST return a new
51
+ * `Response` each call. Returning the SAME already-sent instance fails on the
52
+ * second request with `ERR_BODY_ALREADY_USED` (Bun) / a consumed stream
53
+ * (Node) — use a factory only when the response genuinely varies per request.
54
+ */
55
+ export type StaticRoute = Response | (() => Response)
56
+
36
57
  /** Route spec for the Bun.serve builder. */
37
58
  export interface BakedRoute {
59
+ /**
60
+ * A prebuilt constant response served for GET, bypassing the ingress
61
+ * pipeline entirely. Takes precedence over `read`/`cookies` for GET (the
62
+ * pipeline handler is never wired); other methods (`write`/`echo`/`delete`)
63
+ * are unaffected. See {@link StaticRoute} for the one-shot-body contract.
64
+ *
65
+ * HEAD is intentionally NOT wired for `static` (a bare `Response` carries a
66
+ * body; static responses are probes/constants served over GET). This matches
67
+ * the raw-`read` probe behavior — but note that a route which previously
68
+ * served HEAD via a native `read` now 405s (Node) on HEAD once `static` is
69
+ * set. When `static` is combined with `write`/`echo`/`delete`, those dynamic
70
+ * methods remain wired.
71
+ *
72
+ * Runtime note: `createIngressRouter` treats `static` differently — there,
73
+ * like `raw`, it OWNS THE WHOLE SPEC (the router has no compiled ingress for
74
+ * the other methods; see `RouterRouteSpec.static` in src/ingress/router.ts).
75
+ */
76
+ static?: StaticRoute
38
77
  /**
39
78
  * Wires GET + HEAD read handlers. Accepts an optimized ingress handler OR a
40
79
  * raw `RouteHandler` function (health/metrics probes are raw — see
@@ -113,11 +152,17 @@ export interface CreateIngressServerOptions {
113
152
  routes: Record<string, BakedRoute>
114
153
  fallback?: OptimizedIngressHandler
115
154
  /**
116
- * Invoked when an unhandled error escapes a request handler (currently wired
117
- * on the Node adapter; the Bun path uses runtime.onResponse). Never throws —
118
- * hook failures are swallowed.
155
+ * Invoked when an unhandled error escapes a request handler (raw `read`
156
+ * functions, `responder`/`native` responders, or the Bun/Node adapter
157
+ * catch-all). The error is masked to a generic 500 on the wire. Never
158
+ * throws — hook failures are swallowed.
119
159
  */
120
160
  onError?: (info: { error: Error; request?: Request }) => void
161
+ /**
162
+ * Optional structured-log sink for escaped handler errors (one JSON line per
163
+ * masked 500, message only). Never throws.
164
+ */
165
+ logger?: (line: string) => void
121
166
  /** Node adapter only: ms to receive the complete request (headers + body).
122
167
  * Guards slowloris/trickling requests. Default: 30_000. */
123
168
  requestTimeoutMs?: number
@@ -225,7 +270,7 @@ export function gracefulShutdown(
225
270
  * options — it does not need `port` or the Node-only server fields). */
226
271
  export type BuildRouteHandlersOptions = Pick<
227
272
  CreateIngressServerOptions,
228
- 'routes' | 'fallback' | 'getIp' | 'copyBody'
273
+ 'routes' | 'fallback' | 'getIp' | 'copyBody' | 'onError' | 'logger'
229
274
  >
230
275
 
231
276
  /**
@@ -243,6 +288,16 @@ export function buildRouteHandlers(options: BuildRouteHandlersOptions): {
243
288
  copyBody: options.copyBody,
244
289
  }
245
290
 
291
+ // Masked-500 responder for the escape-capable handlers below. Built once per
292
+ // server: the raw `read` function, the `responder` route, and the `native`
293
+ // route run OUTSIDE the native pipeline (and outside the built-in factories'
294
+ // own error handling). Built-in factories stay unwrapped so the hot path is
295
+ // unchanged.
296
+ const onServerError = createServerErrorHandler({
297
+ onError: options.onError,
298
+ logger: options.logger,
299
+ })
300
+
246
301
  const serverRoutes: Record<string, Record<string, unknown>> = {}
247
302
 
248
303
  for (const [path, spec] of Object.entries(options.routes)) {
@@ -252,13 +307,27 @@ export function buildRouteHandlers(options: BuildRouteHandlersOptions): {
252
307
  bodyTimeoutMs: spec.bodyTimeoutMs,
253
308
  }
254
309
  const methods: Record<string, unknown> = {}
255
-
256
- if (spec.read) {
310
+ const hasStatic = spec.static !== undefined
311
+
312
+ if (hasStatic) {
313
+ // Static promotion: `static` owns GET and is placed in the route table
314
+ // verbatim (a bare Response relies on Bun's native table; see
315
+ // BakedRoute.static). The read/cookies GET wiring below is skipped so
316
+ // the ingress pipeline is NEVER entered for a static route. A
317
+ // function-valued factory is user code with no built-in fault
318
+ // containment, so guard it like a raw `read` function (a bare Response
319
+ // cannot throw and is placed verbatim for zero-copy Bun serving).
320
+ methods.GET =
321
+ typeof spec.static === 'function'
322
+ ? guardRouteHandler(spec.static, onServerError)
323
+ : spec.static
324
+ } else if (spec.read) {
257
325
  if (typeof spec.read === 'function') {
258
326
  // Raw request→Response handler (probes, /metrics): serve GET directly,
259
327
  // outside the ingress pipeline. HEAD is intentionally not wired (a raw
260
- // handler returns a body; probes use GET).
261
- methods.GET = spec.read
328
+ // handler returns a body; probes use GET). Guarded: a raw handler is
329
+ // user code with no built-in fault containment.
330
+ methods.GET = guardRouteHandler(spec.read, onServerError)
262
331
  } else {
263
332
  methods.GET = readHandler(spec.read, routeOpts)
264
333
  methods.HEAD = headHandler(spec.read, routeOpts)
@@ -279,7 +348,7 @@ export function buildRouteHandlers(options: BuildRouteHandlersOptions): {
279
348
  methods.POST = echoHandler(spec.echo, routeOpts)
280
349
  }
281
350
 
282
- if (spec.cookies) {
351
+ if (spec.cookies && !hasStatic) {
283
352
  methods.GET = readHandler(spec.cookies, routeOpts)
284
353
  }
285
354
 
@@ -288,44 +357,65 @@ export function buildRouteHandlers(options: BuildRouteHandlersOptions): {
288
357
  }
289
358
 
290
359
  if (spec.responder) {
291
- // JS responder route: native decides + rejects; JS builds the 2xx.
292
- const responderRoute = nativeResponderRoute(spec.responder.ingress, spec.responder.handler, {
293
- ...routeOpts,
294
- terminalStyle: spec.responder.terminalStyle,
295
- readBody: spec.responder.readBody,
296
- })
297
- const responderMethods = spec.responder.methods ?? ['GET']
298
- for (const m of responderMethods) {
299
- methods[m] = responderRoute
360
+ // JS responder route: native decides + rejects; JS builds the 2xx. The
361
+ // user responder is guarded — a throw/rejection becomes a masked 500.
362
+ // A static GET takes precedence: filter GET out of the wired methods.
363
+ const responderMethods = (spec.responder.methods ?? ['GET']).filter(
364
+ (m) => !(hasStatic && m === 'GET'),
365
+ )
366
+ if (responderMethods.length > 0) {
367
+ const responderRoute = nativeResponderRoute(
368
+ spec.responder.ingress,
369
+ spec.responder.handler,
370
+ {
371
+ ...routeOpts,
372
+ terminalStyle: spec.responder.terminalStyle,
373
+ readBody: spec.responder.readBody,
374
+ },
375
+ )
376
+ const guardedResponderRoute = guardRouteHandler(responderRoute, onServerError)
377
+ for (const m of responderMethods) {
378
+ methods[m] = guardedResponderRoute
379
+ }
300
380
  }
301
381
  }
302
382
 
303
383
  if (spec.native) {
304
384
  // LEAN native-stack responder route: route-wire v3 stack, no envelope.
305
385
  // The compiled route is injected into the pure route factory (DI across
306
- // the purity boundary — the compile touches the dlopen layer here).
307
- const nativeRoute = nativeRouteHandler(
308
- createNativeRoute(spec.native.plan),
309
- spec.native.handler,
310
- {
311
- ...routeOpts,
312
- readBody: spec.native.readBody,
313
- },
386
+ // the purity boundary — the compile touches the dlopen layer here). The
387
+ // user responder is guarded — a throw/rejection becomes a masked 500.
388
+ // A static GET takes precedence: filter GET out of the wired methods.
389
+ const nativeMethods = (spec.native.methods ?? ['GET']).filter(
390
+ (m) => !(hasStatic && m === 'GET'),
314
391
  )
315
- const nativeMethods = spec.native.methods ?? ['GET']
316
- for (const m of nativeMethods) {
317
- methods[m] = nativeRoute
392
+ if (nativeMethods.length > 0) {
393
+ const nativeRoute = nativeRouteHandler(
394
+ createNativeRoute(spec.native.plan),
395
+ spec.native.handler,
396
+ {
397
+ ...routeOpts,
398
+ readBody: spec.native.readBody,
399
+ },
400
+ )
401
+ const guardedNativeRoute = guardRouteHandler(nativeRoute, onServerError)
402
+ for (const m of nativeMethods) {
403
+ methods[m] = guardedNativeRoute
404
+ }
318
405
  }
319
406
  }
320
407
 
321
408
  // CORS preflight (OPTIONS) is served for EVERY route with a NATIVE handler
322
409
  // (not just write routes with a fallback), so read-only routes also answer
323
410
  // preflights with 204/403 from the native pipeline. Raw probe handlers
324
- // (plain functions) are excluded — browsers don't preflight probes.
411
+ // (plain functions) and the GET that `static` replaced are excluded from
412
+ // `primary` — browsers don't preflight probes, and a static route has no
413
+ // pipeline to answer with. A static route that ALSO wires `write`/`echo`/
414
+ // `delete` still gets OPTIONS from that other native handler.
325
415
  const primary =
326
416
  spec.delete ??
327
- (spec.read && typeof spec.read !== 'function' ? spec.read : undefined) ??
328
- spec.cookies ??
417
+ (!hasStatic && spec.read && typeof spec.read !== 'function' ? spec.read : undefined) ??
418
+ (!hasStatic ? spec.cookies : undefined) ??
329
419
  spec.write ??
330
420
  spec.echo
331
421
  if (primary) {
@@ -339,11 +429,10 @@ export function buildRouteHandlers(options: BuildRouteHandlersOptions): {
339
429
  }
340
430
 
341
431
  // ── Path matching (shared with server-node.ts + router.ts) ─────────
342
- // `buildPathMatcher` / `PathMatch` / `safeDecode` live in path-matcher.ts;
343
- // re-exported here for back-compat (they were historically exported from this
344
- // module).
432
+ // `PathMatch` / `safeDecode` live in path-matcher.ts and are not part of this
433
+ // module's surface; only `buildPathMatcher` is re-exported for back-compat.
345
434
 
346
- export { buildPathMatcher, type PathMatch, safeDecode } from './path-matcher'
435
+ export { buildPathMatcher } from './path-matcher'
347
436
 
348
437
  /** Typed options forwarded to `Bun.serve`. */
349
438
  interface BunServerOptions {
@@ -354,6 +443,14 @@ interface BunServerOptions {
354
443
  maxRequestBodySize: number
355
444
  reusePort?: boolean
356
445
  fetch?: (req: Request) => Response | Promise<Response>
446
+ /**
447
+ * Bun's server-level error trap. Invoked when an error escapes a route
448
+ * handler that was NOT already contained by `guardRouteHandler` (e.g. a
449
+ * built-in factory calling user/config code such as `getIp`). Delegates to
450
+ * the shared masked-500 responder so Bun's default handler never surfaces
451
+ * stack/internal detail. See `server-error.ts`.
452
+ */
453
+ error?: (error: Error, req: Request) => Response | Promise<Response>
357
454
  }
358
455
 
359
456
  /** Build a Bun.serve config from pre-baked route handlers. */
@@ -369,6 +466,15 @@ export function createIngressServer(options: CreateIngressServerOptions): BakedS
369
466
  }
370
467
  const { routes: serverRoutes, baseOpts } = buildRouteHandlers(options)
371
468
 
469
+ // Server-level backstop (Bun's `error` option). `buildRouteHandlers` guards
470
+ // the escape-capable route handlers; this covers everything else — built-in
471
+ // factories that call user/config code without a try/catch (notably
472
+ // `resolveIp` → `opts.getIp`) — with the SAME masked 500 + `onError` hook.
473
+ const onServerError = createServerErrorHandler({
474
+ onError: options.onError,
475
+ logger: options.logger,
476
+ })
477
+
372
478
  const serverOptions: BunServerOptions = {
373
479
  hostname: options.hostname ?? '0.0.0.0',
374
480
  port: options.port,
@@ -378,6 +484,7 @@ export function createIngressServer(options: CreateIngressServerOptions): BakedS
378
484
  // to 16 MiB so an oversized request is rejected at the socket without ever
379
485
  // being buffered (route handlers enforce the tighter `maxBodyBytes`).
380
486
  maxRequestBodySize: options.maxRequestBodySize ?? DEFAULT_MAX_REQUEST_BODY_SIZE,
487
+ error: (error, req) => onServerError(error, req),
381
488
  }
382
489
  if (options.reusePort) {
383
490
  serverOptions.reusePort = true
@@ -36,7 +36,7 @@ export const DEFAULT_BAKED_OUTPUT_BUFFER_SIZE = 131_072
36
36
  * (`handle_request_full_sync` allocates `vec![0u8; output_size]`), OOM-ing the
37
37
  * process. Callers that genuinely need more can raise it here.
38
38
  */
39
- export const MAX_OUTPUT_BUFFER_SIZE = 64 * 1024 * 1024
39
+ const MAX_OUTPUT_BUFFER_SIZE = 64 * 1024 * 1024
40
40
 
41
41
  /**
42
42
  * Clamp an ingress output-buffer size into `[min, MAX_OUTPUT_BUFFER_SIZE]` so
@@ -120,6 +120,21 @@ export interface OptimizedIngressHandler {
120
120
  retryAfterSecs?: number,
121
121
  ): [string, string][]
122
122
 
123
+ /**
124
+ * Success-path headers as a ready `Headers` instance: the memoized base
125
+ * (template + reflected origin) plus per-request rate/request-id extras,
126
+ * without rebuilding `Headers` from an array on every response. Optional so
127
+ * hand-rolled mocks need not provide it; callers fall back to
128
+ * `memoizedHeaders(responseHeaders(...))`.
129
+ */
130
+ successHeaders?(
131
+ variant: number,
132
+ requestIdHeader: string | null,
133
+ origin: string | null,
134
+ rateRemaining?: number,
135
+ rateResetSecs?: number,
136
+ ): Headers
137
+
123
138
  terminalHeaders(
124
139
  variant: number,
125
140
  ctx: BakedContext,
@@ -7,6 +7,7 @@
7
7
  // context to the app's own handler. `handleRequest` is a fetch-compatible
8
8
  // convenience that serves the terminal response or a rendered OK response.
9
9
 
10
+ import { abortResponse, isAbortError } from '../ingress/abort'
10
11
  import { readBodyWithLimit } from '../ingress/body'
11
12
  import type { BakedIngressResult } from '../ingress/decode/baked-result'
12
13
  import {
@@ -172,6 +173,11 @@ export function createPipeline(opts: CreatePipelineOptions = {}): IngressPipelin
172
173
  try {
173
174
  body = await readBodyWithLimit(req, maxBodyBytes, true, bodyTimeoutMs)
174
175
  } catch (err) {
176
+ // Client disconnect is the cancelled response, never the
177
+ // `bodyErrorResponse` 400/408/413 fallback.
178
+ if (isAbortError(err)) {
179
+ return { terminal: true, response: abortResponse(), result: null, ctx }
180
+ }
175
181
  return {
176
182
  terminal: true,
177
183
  response: bodyErrorResponse(ingress, req, requestId, err),
package/src/loader/ops.ts CHANGED
@@ -26,7 +26,7 @@ import { decoder } from '../shared/bytes'
26
26
  import type { SchemaValidator } from '../shared/packed'
27
27
 
28
28
  /** Empty bytes sentinel for skip-on-error scalar parity (e.g. AEAD decrypt). */
29
- export const EMPTY_BYTES = new Uint8Array(0)
29
+ const EMPTY_BYTES = new Uint8Array(0)
30
30
 
31
31
  /** Result kinds supported by the loader (mirrors the packed batch wire shapes). */
32
32
  export type LoaderResultKind = 'boolean' | 'number' | 'bigint' | 'bytes'