castrum 0.9.8 → 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 (55) 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 +164 -4
  12. package/dist/index.js +434 -70
  13. package/docs/ENVIRONMENT.md +7 -0
  14. package/docs/RND-CONCURRENCY.md +20 -4
  15. package/docs/superpowers/plans/2026-09-18-elysia-learnings-adoption.md +634 -0
  16. package/index.ts +5 -0
  17. package/package.json +2 -1
  18. package/rust/ffi/task.rs +19 -18
  19. package/rust/ingress/api.rs +21 -5
  20. package/rust/ingress/mod.rs +7 -0
  21. package/rust/ingress/native_route.rs +4 -2
  22. package/rust/ingress/schema_cache.rs +266 -0
  23. package/rust/task/mod.rs +1 -0
  24. package/rust/task/runtime.rs +55 -7
  25. package/rust/task/tests.rs +98 -1
  26. package/src/ingress/abort.ts +48 -0
  27. package/src/ingress/body.ts +104 -8
  28. package/src/ingress/handlers.ts +1 -0
  29. package/src/ingress/health.ts +22 -0
  30. package/src/ingress/index.ts +3 -1
  31. package/src/ingress/router.ts +36 -1
  32. package/src/ingress/routes/common.ts +9 -1
  33. package/src/ingress/routes/echo.ts +10 -0
  34. package/src/ingress/routes/json-write.ts +11 -0
  35. package/src/ingress/routes/native.ts +24 -5
  36. package/src/ingress/routes/options.ts +6 -2
  37. package/src/ingress/routes/responder.ts +25 -5
  38. package/src/ingress/routes/safe-record.ts +57 -0
  39. package/src/ingress/server-error.ts +111 -0
  40. package/src/ingress/server-node.ts +102 -23
  41. package/src/ingress/server.ts +140 -32
  42. package/src/integration/pipeline.ts +6 -0
  43. package/src/native/ffi/build/task.ts +2 -1
  44. package/src/native/ffi/types.ts +6 -1
  45. package/src/native/types.ts +6 -0
  46. package/src/runtime/detect.ts +14 -0
  47. package/src/rust-ffi/context.ts +46 -3
  48. package/src/rust-ffi/scalar/factories.ts +6 -0
  49. package/src/rust-ffi/scalar/interface.ts +14 -0
  50. package/src/rust-ffi/text.ts +5 -4
  51. package/src/shared/buffer-pool.ts +10 -0
  52. package/src/shared/memory.ts +68 -0
  53. package/src/shared/metrics.ts +86 -3
  54. package/src/shared/runtime.ts +7 -0
  55. package/src/task/runtime.ts +52 -9
@@ -0,0 +1,57 @@
1
+ // src/ingress/routes/safe-record.ts — prototype-safe key assignment for
2
+ // request-derived objects.
3
+ //
4
+ // PURE: no addon and no pooled-buffer state. Request keys are attacker
5
+ // controlled, so a key of `__proto__` must not invoke the inherited setter on a
6
+ // plain object. `assignOwn` writes `__proto__` with `Object.defineProperty` (an
7
+ // own enumerable data property) and every other key with plain assignment,
8
+ // which is already safe: `constructor` / `prototype` just become own shadowing
9
+ // properties.
10
+ //
11
+ // Scope: this protects the RECORD itself — it keeps `Object.prototype` (so
12
+ // `hasOwnProperty` / `instanceof Object` still work), gains an own `__proto__`
13
+ // data key, and is never mutated through the setter. It does NOT protect a
14
+ // consumer that blindly merges the record into another object, e.g.
15
+ // `Object.assign({}, rec)`: the own `__proto__` key is still enumerable, so a
16
+ // merge target's inherited setter can fire there. Such consumers must guard
17
+ // their own merge.
18
+
19
+ /** A request-derived key that would invoke the inherited `__proto__` setter. */
20
+ const PROTO_KEY = '__proto__'
21
+
22
+ /**
23
+ * Create an empty plain record (`Object.prototype` is preserved, so
24
+ * `hasOwnProperty` / `instanceof Object` keep working). Safety comes from
25
+ * {@link assignOwn}, not from the container.
26
+ *
27
+ * @returns A fresh, empty `Record<string, unknown>`.
28
+ */
29
+ export function safeRecord(): Record<string, unknown> {
30
+ return {}
31
+ }
32
+
33
+ /**
34
+ * Assign a request-derived key as an OWN enumerable data property.
35
+ *
36
+ * Only `__proto__` is written with `Object.defineProperty` — a plain
37
+ * `target[key] = value` would invoke the inherited `__proto__` setter and
38
+ * mutate the prototype. Every other key (including `constructor` /
39
+ * `prototype`) uses plain assignment: faster, and it merely shadows on the
40
+ * object.
41
+ *
42
+ * @param target - Record to write into (normally from {@link safeRecord}).
43
+ * @param key - Request-derived key (may be `__proto__` / `constructor`).
44
+ * @param value - Request-derived value.
45
+ */
46
+ export function assignOwn(target: Record<string, unknown>, key: string, value: unknown): void {
47
+ if (key === PROTO_KEY) {
48
+ Object.defineProperty(target, key, {
49
+ value,
50
+ enumerable: true,
51
+ writable: true,
52
+ configurable: true,
53
+ })
54
+ return
55
+ }
56
+ target[key] = value
57
+ }
@@ -0,0 +1,111 @@
1
+ // src/ingress/server-error.ts — request-handler fault containment.
2
+ //
3
+ // User-supplied route handlers (a raw `spec.read`, a `responder`, a `native`
4
+ // responder) run OUTSIDE the native pipeline. On Bun a sync throw / async
5
+ // rejection would otherwise escape to Bun's default handler (which is not
6
+ // castrum-controlled and may surface stack/internal detail); on Node it hit a
7
+ // local generic-500 construction. This module provides ONE masked-500 responder
8
+ // shared by both runtimes plus a guard that contains sync throws and async
9
+ // rejections. The response body is the pre-encoded internal error body — the
10
+ // thrown message/stack is NEVER placed on the wire (info-leak rule).
11
+
12
+ import { memoizedHeaders } from './headers/memoized-headers'
13
+ import { ERROR_BODIES } from './response/error-bodies'
14
+ import type { RouteHandler } from './server'
15
+
16
+ /** Options for {@link createServerErrorHandler}. */
17
+ export interface ServerErrorOptions {
18
+ /**
19
+ * Observability hook for an escaped handler error. Receives the real `Error`
20
+ * (unmasked — server-side only). Never throws: a failing hook is swallowed so
21
+ * it cannot break the masked response.
22
+ */
23
+ onError?: (info: { error: Error; request?: Request }) => void
24
+ /**
25
+ * Structured-log sink: one JSON line per escaped error (message only, no
26
+ * stack, no response leak). Never throws.
27
+ */
28
+ logger?: (line: string) => void
29
+ }
30
+
31
+ /** Coerce an unknown thrown value to an `Error` for the hook/logger. */
32
+ function toError(value: unknown): Error {
33
+ return value instanceof Error ? value : new Error(String(value))
34
+ }
35
+
36
+ const INTERNAL_HEADERS: readonly [string, string][] = [
37
+ ['content-type', 'application/json; charset=utf-8'],
38
+ ]
39
+
40
+ /**
41
+ * Build a masked-500 responder. The returned function never throws, always
42
+ * answers with the pre-encoded internal error body, and runs the optional
43
+ * `onError`/`logger` hooks in a contained way.
44
+ *
45
+ * @param opts - Optional observability hooks.
46
+ * @returns A `(error, req?) => Response` responder for escaped handler errors.
47
+ * @example
48
+ * ```ts
49
+ * const onServerError = createServerErrorHandler({ onError: console.error })
50
+ * onServerError(new Error('boom')) // Response(500)
51
+ * ```
52
+ */
53
+ export function createServerErrorHandler(
54
+ opts: ServerErrorOptions,
55
+ ): (error: unknown, req?: Request) => Response {
56
+ const headers = memoizedHeaders(INTERNAL_HEADERS)
57
+ return (error, req) => {
58
+ const err = toError(error)
59
+ if (opts.onError) {
60
+ try {
61
+ opts.onError({ error: err, request: req })
62
+ } catch {
63
+ // Observability must never break the response.
64
+ }
65
+ }
66
+ if (opts.logger) {
67
+ try {
68
+ opts.logger(
69
+ JSON.stringify({
70
+ level: 'error',
71
+ msg: 'unhandled_handler_error',
72
+ error: err.message,
73
+ }),
74
+ )
75
+ } catch {
76
+ // Logging must never break the response.
77
+ }
78
+ }
79
+ return new Response(ERROR_BODIES.internal, { status: 500, headers })
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Wrap a route handler so a sync throw or an async rejection is contained and
85
+ * turned into the shared masked 500. The success path is passed through
86
+ * unchanged.
87
+ *
88
+ * @param handler - The route handler to guard.
89
+ * @param onServerError - The masked-500 responder (see
90
+ * {@link createServerErrorHandler}).
91
+ * @returns A `RouteHandler` with the same signature and success semantics.
92
+ */
93
+ export function guardRouteHandler(
94
+ handler: RouteHandler,
95
+ onServerError: (error: unknown, req?: Request) => Response,
96
+ ): RouteHandler {
97
+ return (req, srv, params) => {
98
+ try {
99
+ const out = handler(req, srv, params)
100
+ // Duck-type thenables (cross-realm Promises/custom thenables fail
101
+ // `instanceof Promise`). A `Response` never exposes `then`.
102
+ const thenable = out as Promise<Response> | null | undefined
103
+ if (thenable !== null && thenable !== undefined && typeof thenable.then === 'function') {
104
+ return thenable.catch((e) => onServerError(e, req))
105
+ }
106
+ return out
107
+ } catch (e) {
108
+ return onServerError(e, req)
109
+ }
110
+ }
111
+ }
@@ -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
 
@@ -77,6 +90,16 @@ function nodeRequestToWebRequest(req: IncomingMessage): Request {
77
90
 
78
91
  const init: RequestInit & { duplex?: 'half' } = { method, headers }
79
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
+
80
103
  // Stream the request body through (the route handlers call
81
104
  // `req.arrayBuffer()` which drains this stream).
82
105
  if (hasBody && !bodyForbidden) {
@@ -161,6 +184,54 @@ function requestHasBody(req: IncomingMessage): boolean {
161
184
  )
162
185
  }
163
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
+
164
235
  /** Build a request listener that dispatches to the shared route map. */
165
236
  function makeRequestListener(
166
237
  options: CreateIngressServerOptions,
@@ -169,6 +240,11 @@ function makeRequestListener(
169
240
  ): (req: IncomingMessage, res: ServerResponse) => Promise<void> {
170
241
  // Exact paths win; `:param`/`*` patterns are matched most-specific-first.
171
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
+ })
172
248
 
173
249
  return async (req, res) => {
174
250
  try {
@@ -201,14 +277,27 @@ function makeRequestListener(
201
277
  const method = webReq.method ?? 'GET'
202
278
 
203
279
  const matched = matchPath(pathname)
204
- const handler = matched?.methods?.[method] as RouteHandler | undefined
280
+ const entry = matched?.methods?.[method]
205
281
 
206
282
  let response: Response
207
- if (handler !== undefined && matched !== undefined) {
283
+ if (entry !== undefined && matched !== undefined) {
208
284
  // The ingress pipeline does not echo path params, so matching only
209
285
  // selects the handler; the extracted params are passed as a 3rd arg
210
286
  // (ignored by current handlers) for future/raw-handler use.
211
- 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
+ }
212
301
  } else if (options.fallback !== undefined) {
213
302
  const fallback = fallbackHandler(options.fallback, baseOpts)
214
303
  response = await fallback(webReq, req)
@@ -218,28 +307,18 @@ function makeRequestListener(
218
307
 
219
308
  await writeResponse(res, response)
220
309
  } catch (err) {
221
- // 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.
222
312
  if (!res.headersSent) {
223
- res.statusCode = 500
224
- res.setHeader('content-type', 'application/json')
225
- res.end(
226
- JSON.stringify({
227
- error: { code: 'internal_error', message: 'Internal Server Error' },
228
- }),
229
- )
313
+ await writeResponse(res, onServerError(err))
230
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)
231
320
  res.destroy()
232
321
  }
233
- // NOTE: surfaced via the onError hook (observability phase).
234
- if (options.onError) {
235
- try {
236
- options.onError({
237
- error: err instanceof Error ? err : new Error(String(err)),
238
- })
239
- } catch {
240
- // hook must never crash the server
241
- }
242
- }
243
322
  }
244
323
  }
245
324
  }
@@ -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) {
@@ -353,6 +443,14 @@ interface BunServerOptions {
353
443
  maxRequestBodySize: number
354
444
  reusePort?: boolean
355
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>
356
454
  }
357
455
 
358
456
  /** Build a Bun.serve config from pre-baked route handlers. */
@@ -368,6 +466,15 @@ export function createIngressServer(options: CreateIngressServerOptions): BakedS
368
466
  }
369
467
  const { routes: serverRoutes, baseOpts } = buildRouteHandlers(options)
370
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
+
371
478
  const serverOptions: BunServerOptions = {
372
479
  hostname: options.hostname ?? '0.0.0.0',
373
480
  port: options.port,
@@ -377,6 +484,7 @@ export function createIngressServer(options: CreateIngressServerOptions): BakedS
377
484
  // to 16 MiB so an oversized request is rejected at the socket without ever
378
485
  // being buffered (route handlers enforce the tighter `maxBodyBytes`).
379
486
  maxRequestBodySize: options.maxRequestBodySize ?? DEFAULT_MAX_REQUEST_BODY_SIZE,
487
+ error: (error, req) => onServerError(error, req),
380
488
  }
381
489
  if (options.reusePort) {
382
490
  serverOptions.reusePort = true
@@ -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),
@@ -37,7 +37,8 @@ export function buildTask(
37
37
  return {
38
38
  taskInit: (n) => Number(init(n)),
39
39
  // `args` is copied onto the worker by Rust, so the caller's pooled buffer
40
- // may be released as soon as this returns.
40
+ // may be released as soon as this returns. The return code is `1` accepted,
41
+ // `2` overloaded (bounded work queue full), `0` invalid args.
41
42
  taskSubmit: (op, args, taskId) => Number(submit(op, args, lenOrView(args), taskId)),
42
43
  taskSubmitOut: (op, args, taskId, output) =>
43
44
  Number(submitOut(op, args, lenOrView(args), taskId, output, lenOrView(output))),
@@ -515,18 +515,23 @@ export interface BunFFI {
515
515
  // ── Off-thread task runtime (castrum_task_*) ──────────────────────
516
516
  /** Start the shared native task pool; 0 = started, 1 = already running. */
517
517
  taskInit(threads: number): number
518
- /** Submit a task op with packed `args` under `taskId`; 1 = accepted. */
518
+ /**
519
+ * Submit a task op with packed `args` under `taskId`. Returns `1` accepted,
520
+ * `2` overloaded (bounded queue full), `0` invalid args.
521
+ */
519
522
  taskSubmit(op: number, args: Uint8Array, taskId: number): number
520
523
  /**
521
524
  * Zero-copy submit: the op writes its result DIRECTLY into `output` on the
522
525
  * pool thread. `output` must be a stable, caller-owned buffer that stays
523
526
  * alive (and untouched) until the completion is drained — the runtime holds
524
527
  * the reference. The completion body is the 8-byte LE written length.
528
+ * Returns `1` accepted, `2` overloaded, `0` invalid args.
525
529
  */
526
530
  taskSubmitOut(op: number, args: Uint8Array, taskId: number, output: Uint8Array): number
527
531
  /**
528
532
  * Zero-copy INPUT: only `hdr` is copied; `data` is read in place by the pool
529
533
  * thread, so the caller must keep it alive (and untouched) until completion.
534
+ * Returns `1` accepted, `2` overloaded, `0` invalid args.
530
535
  */
531
536
  taskSubmitSlice(op: number, hdr: Uint8Array, data: Uint8Array, taskId: number): number
532
537
  /**