castrum 0.9.8 → 0.9.10

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 (61) hide show
  1. package/Cargo.lock +11 -1
  2. package/Cargo.toml +17 -3
  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/bun-builtins-decision-matrix.md +17 -7
  16. package/docs/superpowers/plans/2026-09-18-elysia-learnings-adoption.md +634 -0
  17. package/index.ts +5 -0
  18. package/package.json +2 -1
  19. package/rust/crypto/base64.rs +472 -57
  20. package/rust/crypto/hashing.rs +68 -0
  21. package/rust/ffi/task.rs +19 -18
  22. package/rust/ingress/api.rs +21 -5
  23. package/rust/ingress/mod.rs +7 -0
  24. package/rust/ingress/native_route.rs +4 -2
  25. package/rust/ingress/schema_cache.rs +266 -0
  26. package/rust/payload/sse.rs +78 -5
  27. package/rust/task/mod.rs +1 -0
  28. package/rust/task/runtime.rs +55 -7
  29. package/rust/task/tests.rs +98 -1
  30. package/rust/util/text.rs +112 -10
  31. package/rust/util/validation.rs +34 -0
  32. package/src/ingress/abort.ts +48 -0
  33. package/src/ingress/body.ts +104 -8
  34. package/src/ingress/handlers.ts +1 -0
  35. package/src/ingress/health.ts +22 -0
  36. package/src/ingress/index.ts +3 -1
  37. package/src/ingress/router.ts +36 -1
  38. package/src/ingress/routes/common.ts +9 -1
  39. package/src/ingress/routes/echo.ts +10 -0
  40. package/src/ingress/routes/json-write.ts +11 -0
  41. package/src/ingress/routes/native.ts +24 -5
  42. package/src/ingress/routes/options.ts +6 -2
  43. package/src/ingress/routes/responder.ts +25 -5
  44. package/src/ingress/routes/safe-record.ts +57 -0
  45. package/src/ingress/server-error.ts +111 -0
  46. package/src/ingress/server-node.ts +102 -23
  47. package/src/ingress/server.ts +140 -32
  48. package/src/integration/pipeline.ts +6 -0
  49. package/src/native/ffi/build/task.ts +2 -1
  50. package/src/native/ffi/types.ts +6 -1
  51. package/src/native/types.ts +6 -0
  52. package/src/runtime/detect.ts +14 -0
  53. package/src/rust-ffi/context.ts +46 -3
  54. package/src/rust-ffi/scalar/factories.ts +6 -0
  55. package/src/rust-ffi/scalar/interface.ts +14 -0
  56. package/src/rust-ffi/text.ts +5 -4
  57. package/src/shared/buffer-pool.ts +10 -0
  58. package/src/shared/memory.ts +68 -0
  59. package/src/shared/metrics.ts +86 -3
  60. package/src/shared/runtime.ts +7 -0
  61. package/src/task/runtime.ts +52 -9
@@ -11,6 +11,11 @@
11
11
  // "/livez": { read: healthHandler() },
12
12
  // }});
13
13
  //
14
+ // For probes that are truly constant, prefer static promotion — it skips the
15
+ // JS ingress pipeline entirely:
16
+ //
17
+ // createIngressServer({ routes: { "/livez": { static: staticLiveness() } } });
18
+ //
14
19
  // The liveness probe NEVER touches dependencies (process alive? -> 200).
15
20
  // The readiness probe runs the optional `check` (deps up? -> 200/503).
16
21
  // The health probe is an alias of liveness (process up) plus optional check.
@@ -36,6 +41,23 @@ export function livenessHandler() {
36
41
  return (_req: Request): Response => jsonResponse(200, { status: 'ok' })
37
42
  }
38
43
 
44
+ /**
45
+ * Liveness probe as a PREBUILT `Response`, for static route promotion.
46
+ *
47
+ * Wire it as `{ static: staticLiveness() }` so `Bun.serve` snapshots the
48
+ * constant body into its native route table and serves `/livez` without
49
+ * entering the JavaScript ingress pipeline at all (see `BakedRoute.static` in
50
+ * src/ingress/server.ts). On the node:http adapter the bare `Response` is
51
+ * re-materialized per request from cached body bytes, so the one-shot body is
52
+ * never replayed. Unlike {@link readinessHandler}, it never runs a dependency
53
+ * check.
54
+ *
55
+ * @returns A fresh 200 `{"status":"ok"}` `Response`.
56
+ */
57
+ export function staticLiveness(): Response {
58
+ return jsonResponse(200, { status: 'ok' })
59
+ }
60
+
39
61
  /**
40
62
  * Readiness probe: runs the optional async `check` (dependency readiness).
41
63
  * Returns `200 {"status":"ready"}` when the check passes (or no check is
@@ -22,6 +22,8 @@ export {
22
22
  serializeTraceParent,
23
23
  } from '../shared/trace'
24
24
  // ── Re-export the full public ingress API ─────────────────────────
25
+ // Request cancellation helpers (client-disconnect → 499).
26
+ export { ABORT_CODE, abortResponse, isAbortError } from './abort'
25
27
  export * from './constants'
26
28
  export * from './errors'
27
29
  export { createIngressFast, FastIngressResult } from './fast'
@@ -30,7 +32,7 @@ export type { CorsOptions, CorsStaticStrings } from './headers/cors'
30
32
  export type { HeaderTemplate, ResponseBuildContext } from './headers/fast-templates'
31
33
  export { buildResponseContext, headersForResult } from './headers/fast-templates'
32
34
  export type { SecurityHeadersOptions } from './headers/hsts'
33
- export { healthHandler, livenessHandler, readinessHandler } from './health'
35
+ export { healthHandler, livenessHandler, readinessHandler, staticLiveness } from './health'
34
36
  export type { IngressMetrics } from './metrics'
35
37
  // ── Observability (zero-dep): metrics, health probes ─────────────
36
38
  export { createIngressMetrics, metricsHandler } from './metrics'
@@ -27,7 +27,7 @@ import type { NativeRoutePlan } from './native-route'
27
27
  import { type IngressHandlerOptions, warnRateLimitWithoutGetIp } from './options'
28
28
  import type { PathMatch } from './path-matcher'
29
29
  import { buildPathMatcher } from './path-matcher'
30
- import type { BakedRoute, RouteHandler } from './server'
30
+ import type { BakedRoute, RouteHandler, StaticRoute } from './server'
31
31
  import { buildRouteHandlers } from './server'
32
32
  import type { BakedContext, NativeResponder, OptimizedIngressHandler, TerminalStyle } from './types'
33
33
 
@@ -88,6 +88,20 @@ export interface RouterRouteSpec {
88
88
  /** Terminal envelope style for this route's responder (default: router-level
89
89
  * `terminalStyle` or `'castrum'`). */
90
90
  terminalStyle?: TerminalStyle
91
+ /**
92
+ * A prebuilt constant response served directly for GET, OUTSIDE the ingress
93
+ * pipeline (static route promotion). Like `raw`, it OWNS THE WHOLE SPEC: the
94
+ * router compiles no `IngressInner` for a `static` route, so `read`/`write`/
95
+ * `echo`/etc. on the same spec are ignored.
96
+ *
97
+ * This differs from `BakedRoute.static` in src/ingress/server.ts, where
98
+ * `static` only takes GET precedence and leaves `write`/`echo`/`delete`
99
+ * wired — the router cannot mirror that because a `static`-only spec has no
100
+ * compiled ingress to hand those methods. Do not unify the two semantics.
101
+ *
102
+ * See `BakedRoute.static` for the one-shot-body contract.
103
+ */
104
+ static?: StaticRoute
91
105
  /**
92
106
  * A raw request→Response handler served directly for GET, OUTSIDE the
93
107
  * ingress pipeline (health/metrics probes, /metrics). When set, `read`/
@@ -175,6 +189,17 @@ export function createIngressRouter(options: CreateIngressRouterOptions): Ingres
175
189
  if (options.copyBody !== undefined) baseOpts.copyBody = options.copyBody
176
190
 
177
191
  for (const [path, spec] of Object.entries(options.routes)) {
192
+ if (spec.static !== undefined) {
193
+ // Static promotion: a prebuilt constant response owns the route. Placed
194
+ // verbatim so Bun.serve can serve it from its native table (a bare
195
+ // Response) with zero JS.
196
+ bakedRoutes[path] = {
197
+ static: spec.static,
198
+ maxBodyBytes: spec.maxBodyBytes,
199
+ bodyTimeoutMs: spec.bodyTimeoutMs,
200
+ }
201
+ continue
202
+ }
178
203
  if (spec.raw) {
179
204
  // Raw handler: served directly, outside the pipeline.
180
205
  bakedRoutes[path] = { read: spec.raw as RouteHandler }
@@ -277,6 +302,16 @@ export function createIngressRouter(options: CreateIngressRouterOptions): Ingres
277
302
  if (typeof methodHandler === 'function') {
278
303
  return (methodHandler as RouteHandler)(req, srv, matched.params)
279
304
  }
305
+ if (methodHandler instanceof Response) {
306
+ // Static route (bare Response value). `instanceof Response` assumes the
307
+ // value was built with the global `Response` constructor (the one this
308
+ // router also uses). `clone()` tees the body without consuming the
309
+ // original, so every request gets a fresh, complete body. A `static`
310
+ // factory is a function and is handled above. The cast bridges Bun's
311
+ // `Response.clone()` return type (which resolves against the bundled
312
+ // fetch types) to this module's global `Response`.
313
+ return methodHandler.clone() as unknown as Response
314
+ }
280
315
  // A route matched but this method isn't wired — 405, or a bare OPTIONS 204
281
316
  // when the route answers preflights.
282
317
  if (req.method === 'OPTIONS' && typeof matched.methods.OPTIONS === 'function') {
@@ -1,5 +1,6 @@
1
1
  // src/ingress/routes/common.ts — Shared route-handler options + helpers.
2
2
 
3
+ import { abortResponse } from '../abort'
3
4
  import type { BakedIngressResult } from '../decode/baked-result'
4
5
  import { memoizedHeaders } from '../headers/memoized-headers'
5
6
  import { secondsFromMs } from '../shared'
@@ -93,11 +94,18 @@ export function buildSuccessInit(
93
94
  * runner: resolve the client IP once and dispatch through the native pipeline
94
95
  * (`ingress.run` with a null body). Used by the read / head / fallback route
95
96
  * factories whose responder never needs the request object.
97
+ *
98
+ * A disconnected client (`req.signal?.aborted`) short-circuits to the 499
99
+ * cancelled response BEFORE `ingress.run`, so no pooled output buffer is
100
+ * acquired for a request that can no longer be delivered.
96
101
  */
97
102
  export function runBaked(
98
103
  ingress: OptimizedIngressHandler,
99
104
  opts: BakedHandlerOptions,
100
105
  respond: (result: BakedIngressResult, ctx: BakedContext) => Response,
101
106
  ): (req: Request, srv?: unknown) => Response {
102
- return (req, srv) => ingress.run<Response>(req, resolveIp(req, srv, opts), null, respond)
107
+ return (req, srv) => {
108
+ if (req.signal?.aborted) return abortResponse()
109
+ return ingress.run<Response>(req, resolveIp(req, srv, opts), null, respond)
110
+ }
103
111
  }
@@ -1,5 +1,6 @@
1
1
  // src/ingress/routes/echo.ts — Pre-baked echo handler.
2
2
 
3
+ import { abortResponse, isAbortError } from '../abort'
3
4
  import { readBodyWithLimit } from '../body'
4
5
  import { HV_JSON } from '../constants'
5
6
  import { ERROR_BODIES } from '../response/error-bodies'
@@ -53,6 +54,8 @@ export function echoHandler(
53
54
  return async (req, srv) => {
54
55
  const ip = resolveIp(req, srv, opts)
55
56
 
57
+ if (req.signal?.aborted) return abortResponse()
58
+
56
59
  const prep = ingress.run<{
57
60
  terminal?: Response
58
61
  headers?: ReadonlyArray<[string, string]>
@@ -79,6 +82,9 @@ export function echoHandler(
79
82
 
80
83
  if (prep.terminal) return prep.terminal
81
84
 
85
+ // The client may have disconnected while the native prep ran.
86
+ if (req.signal?.aborted) return abortResponse()
87
+
82
88
  const baseHeaders: ReadonlyArray<[string, string]> = prep.headers ?? []
83
89
 
84
90
  const requestedContentType = safeEchoContentType(
@@ -125,11 +131,15 @@ export function echoHandler(
125
131
  // oversized or trickling body before rejecting it.
126
132
  const bodyBytes = await readBodyWithLimit(req, maxBodyBytes, true, bodyTimeoutMs)
127
133
 
134
+ // Disconnect during the read must not be answered with a body.
135
+ if (req.signal?.aborted) return abortResponse()
136
+
128
137
  return new Response(bodyBytes.byteLength > 0 ? bodyBytes : null, {
129
138
  status: 200,
130
139
  headers: withEchoHeaders(requestedContentType),
131
140
  })
132
141
  } catch (err) {
142
+ if (isAbortError(err)) return abortResponse()
133
143
  const code = (err as { code?: string } | null)?.code
134
144
  const isTooLarge = code === 'BODY_TOO_LARGE'
135
145
  const isTimeout = code === 'REQUEST_TIMEOUT'
@@ -2,6 +2,7 @@
2
2
  // (POST/PUT/PATCH).
3
3
 
4
4
  import { decodeUtf8 } from '../../shared/codec'
5
+ import { abortResponse, isAbortError } from '../abort'
5
6
  import { readBodyWithLimit } from '../body'
6
7
  import { DEFAULT_BODY_TIMEOUT_MS, DEFAULT_MAX_BODY_BYTES } from '../shared'
7
8
  import type { OptimizedIngressHandler } from '../types'
@@ -58,6 +59,10 @@ export function jsonWriteHandler(
58
59
  return async (req, srv) => {
59
60
  const ip = resolveIp(req, srv, opts)
60
61
 
62
+ // Client already gone: do no work (no content-type check, no body read,
63
+ // no native run). A disconnect DURING the body read is handled below.
64
+ if (req.signal?.aborted) return abortResponse()
65
+
61
66
  const contentType = req.headers.get('content-type') ?? ''
62
67
  if (!isJsonContentType(contentType)) {
63
68
  // Always 415 for an unsupported media type. Do NOT short-circuit on the
@@ -87,6 +92,9 @@ export function jsonWriteHandler(
87
92
  // never fully buffered first.
88
93
  bodyBytes = await readBodyWithLimit(req, maxBodyBytes, true, bodyTimeoutMs)
89
94
  } catch (err) {
95
+ // Client disconnect is NOT a bad request / timeout: return the cancelled
96
+ // response without running the native pipeline.
97
+ if (isAbortError(err)) return abortResponse()
90
98
  const code = (err as { code?: string } | null)?.code
91
99
  const isTooLarge = code === 'BODY_TOO_LARGE'
92
100
  return fallback.run(req, ip, null, (result, ctx) => {
@@ -104,6 +112,9 @@ export function jsonWriteHandler(
104
112
  })
105
113
  }
106
114
 
115
+ // The client may have disconnected while the body was streaming in.
116
+ if (req.signal?.aborted) return abortResponse()
117
+
107
118
  return ingress.run(req, ip, bodyBytes, (result, ctx) => {
108
119
  const terminal = ingress.terminalResponse(req, result, ctx)
109
120
  if (terminal) return terminal
@@ -21,6 +21,7 @@
21
21
 
22
22
  import { decoder } from '../../shared/bytes'
23
23
  import { generateRequestId } from '../../shared/request-id'
24
+ import { abortResponse, isAbortError } from '../abort'
24
25
  import { readBodyWithLimit } from '../body'
25
26
  import type { NativeRoute } from '../native-route'
26
27
  import type { RouteWireResult } from '../packing/route-wire'
@@ -29,6 +30,7 @@ import { DEFAULT_BODY_TIMEOUT_MS, DEFAULT_MAX_BODY_BYTES } from '../shared'
29
30
  import type { NativeRequestContext, NativeResponder } from '../types'
30
31
  import type { BakedHandlerOptions } from './common'
31
32
  import { resolveIp } from './common'
33
+ import { assignOwn, safeRecord } from './safe-record'
32
34
 
33
35
  /** Options for {@link nativeRouteHandler}. */
34
36
  export interface NativeRouteHandlerOptions extends BakedHandlerOptions {
@@ -36,11 +38,22 @@ export interface NativeRouteHandlerOptions extends BakedHandlerOptions {
36
38
  readBody?: boolean
37
39
  }
38
40
 
39
- /** The `+`-to-space / `%XX` decoding is done natively; keys are last-wins. */
40
- function pairsToRecord(pairs: ReadonlyArray<[string, string]>): Record<string, string> {
41
- const out: Record<string, string> = {}
42
- for (const [k, v] of pairs) out[k] = v
43
- return out
41
+ /**
42
+ * Build a prototype-safe record from native packed pairs. The `+`-to-space /
43
+ * `%XX` decoding is done natively; keys are last-wins. Keys are written as OWN
44
+ * data via {@link assignOwn}: `__proto__` is defined (never routed through the
45
+ * inherited setter), and other keys — including `constructor` / `prototype` —
46
+ * are plain own shadowing properties. The record keeps `Object.prototype`.
47
+ *
48
+ * Exported for the prototype-pollution tests.
49
+ *
50
+ * @param pairs - Decoded `[key, value]` pairs from the native route stack.
51
+ * @returns A record with `Object.prototype` preserved (last value wins per key).
52
+ */
53
+ export function pairsToRecord(pairs: ReadonlyArray<[string, string]>): Record<string, string> {
54
+ const out = safeRecord()
55
+ for (const [k, v] of pairs) assignOwn(out, k, v)
56
+ return out as Record<string, string>
44
57
  }
45
58
 
46
59
  /**
@@ -78,6 +91,8 @@ export function nativeRouteHandler(
78
91
 
79
92
  return async (req, srv, _params) => {
80
93
  const ip = resolveIp(req, srv, opts)
94
+ // Client already gone: no request-id work, body read, or native run.
95
+ if (req.signal?.aborted) return abortResponse()
81
96
  const requestId = decoder.decode(generateRequestId())
82
97
 
83
98
  let body: Uint8Array | null = null
@@ -85,10 +100,14 @@ export function nativeRouteHandler(
85
100
  try {
86
101
  body = await readBodyWithLimit(req, maxBodyBytes, true, bodyTimeoutMs)
87
102
  } catch (err) {
103
+ // Client disconnect: the cancelled response, never a 400/408/413.
104
+ if (isAbortError(err)) return abortResponse()
88
105
  const code = (err as Error & { code?: string }).code
89
106
  const status = code === 'REQUEST_TIMEOUT' ? 408 : code === 'BODY_TOO_LARGE' ? 413 : 400
90
107
  return terminal(status, 'BAD_REQUEST', 'Request body read failed')
91
108
  }
109
+ // Disconnect while the body was streaming in.
110
+ if (req.signal?.aborted) return abortResponse()
92
111
  }
93
112
 
94
113
  // Extract the query substring + Cookie header (the only request inputs the
@@ -1,5 +1,6 @@
1
1
  // src/ingress/routes/options.ts — Pre-baked OPTIONS handler (CORS preflight).
2
2
 
3
+ import { abortResponse } from '../abort'
3
4
  import type { OptimizedIngressHandler } from '../types'
4
5
  import { type BakedHandlerOptions, buildSuccessInit, resolveIp } from './common'
5
6
 
@@ -16,11 +17,14 @@ export function optionsHandler(
16
17
  ingress: OptimizedIngressHandler,
17
18
  opts: BakedHandlerOptions = {},
18
19
  ): (req: Request, srv?: unknown) => Response {
19
- return (req, srv) =>
20
- ingress.run<Response>(req, resolveIp(req, srv, opts), null, (result, ctx) => {
20
+ return (req, srv) => {
21
+ // Client already gone: no pipeline run, no pooled output buffer.
22
+ if (req.signal?.aborted) return abortResponse()
23
+ return ingress.run<Response>(req, resolveIp(req, srv, opts), null, (result, ctx) => {
21
24
  const terminal = ingress.terminalResponse(req, result, ctx)
22
25
  if (terminal) return terminal
23
26
 
24
27
  return new Response(null, buildSuccessInit(ingress, result, ctx, 204))
25
28
  })
29
+ }
26
30
  }
@@ -16,6 +16,7 @@
16
16
 
17
17
  import { decoder } from '../../shared/bytes'
18
18
  import { generateRequestId } from '../../shared/request-id'
19
+ import { abortResponse, isAbortError } from '../abort'
19
20
  import { readBodyWithLimit } from '../body'
20
21
  import type { BakedIngressResult } from '../decode/baked-result'
21
22
  import { errorCodeName, errorMessage } from '../errors'
@@ -68,12 +69,25 @@ function buildIgngexTerminal(result: BakedIngressResult, _ctx: BakedContext): Re
68
69
  return new Response(body, { status, headers })
69
70
  }
70
71
 
71
- /** Parse a `queryJson()`/`cookiesJson()` section into an object (empty on failure). */
72
- function parseSection(json: string): Record<string, unknown> {
72
+ /**
73
+ * Parse a `queryJson()`/`cookiesJson()` section into a record (empty on
74
+ * failure).
75
+ *
76
+ * Prototype-safe by construction: `JSON.parse` creates `__proto__` as an OWN
77
+ * enumerable data property (it never invokes the setter), so the returned
78
+ * object keeps `Object.prototype` and carries `__proto__` as inert own data.
79
+ *
80
+ * Exported for the prototype-pollution tests.
81
+ *
82
+ * @param json - The section JSON emitted by the native pipeline.
83
+ * @returns The decoded object, or an empty record for a non-object / malformed
84
+ * section.
85
+ */
86
+ export function parseSection(json: string): Record<string, unknown> {
73
87
  try {
74
- const v: unknown = JSON.parse(json)
75
- return v !== null && typeof v === 'object' && !Array.isArray(v)
76
- ? (v as Record<string, unknown>)
88
+ const parsed: unknown = JSON.parse(json)
89
+ return parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)
90
+ ? (parsed as Record<string, unknown>)
77
91
  : {}
78
92
  } catch {
79
93
  return {}
@@ -97,6 +111,8 @@ export function nativeResponderRoute(
97
111
 
98
112
  return async (req, srv, _params) => {
99
113
  const ip = resolveIp(req, srv, opts)
114
+ // Client already gone: no request-id work, body read, or native run.
115
+ if (req.signal?.aborted) return abortResponse()
100
116
  // The request-id the framework surfaces (same source as the pre-baked path's
101
117
  // `ctx.requestIdHeader`): the baked result does not carry it, so generate it
102
118
  // here — the native pipeline uses the same rid for its internal frame.
@@ -109,6 +125,8 @@ export function nativeResponderRoute(
109
125
  try {
110
126
  body = await readBodyWithLimit(req, maxBodyBytes, true, bodyTimeoutMs)
111
127
  } catch (err) {
128
+ // Client disconnect: the cancelled response, never a 400/408/413.
129
+ if (isAbortError(err)) return abortResponse()
112
130
  const code = (err as Error & { code?: string }).code
113
131
  const status = code === 'REQUEST_TIMEOUT' ? 408 : code === 'BODY_TOO_LARGE' ? 413 : 400
114
132
  const payload = JSON.stringify({ error: 'Bad Request', status, code: 'BAD_REQUEST' })
@@ -116,6 +134,8 @@ export function nativeResponderRoute(
116
134
  headers.set('content-type', 'application/json; charset=utf-8')
117
135
  return new Response(payload, { status, headers })
118
136
  }
137
+ // Disconnect while the body was streaming in.
138
+ if (req.signal?.aborted) return abortResponse()
119
139
  }
120
140
 
121
141
  let terminal: Response | null = null
@@ -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
+ }