@nifrajs/core 2.11.0 → 2.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (180) hide show
  1. package/dist/assurance.d.ts +61 -3
  2. package/dist/assurance.d.ts.map +1 -1
  3. package/dist/assurance.js +152 -5
  4. package/dist/assurance.js.map +1 -1
  5. package/dist/capabilities.d.ts +9 -1
  6. package/dist/capabilities.d.ts.map +1 -1
  7. package/dist/capabilities.js +5 -1
  8. package/dist/capabilities.js.map +1 -1
  9. package/dist/diff.d.ts +2 -1
  10. package/dist/diff.d.ts.map +1 -1
  11. package/dist/diff.js +3 -1
  12. package/dist/diff.js.map +1 -1
  13. package/dist/errors.d.ts +1 -1
  14. package/dist/errors.d.ts.map +1 -1
  15. package/dist/errors.js.map +1 -1
  16. package/dist/evidence.d.ts +89 -0
  17. package/dist/evidence.d.ts.map +1 -0
  18. package/dist/evidence.js +170 -0
  19. package/dist/evidence.js.map +1 -0
  20. package/dist/execution-policy.d.ts +23 -0
  21. package/dist/execution-policy.d.ts.map +1 -0
  22. package/dist/execution-policy.js +28 -0
  23. package/dist/execution-policy.js.map +1 -0
  24. package/dist/index.d.ts +1 -1
  25. package/dist/index.js +1 -1
  26. package/dist/internal/route-assurance.d.ts +19 -0
  27. package/dist/internal/route-assurance.d.ts.map +1 -1
  28. package/dist/internal/route-assurance.js +38 -5
  29. package/dist/internal/route-assurance.js.map +1 -1
  30. package/dist/internal/route-execution.d.ts +18 -0
  31. package/dist/internal/route-execution.d.ts.map +1 -1
  32. package/dist/internal/route-execution.js +67 -1
  33. package/dist/internal/route-execution.js.map +1 -1
  34. package/dist/manifest.d.ts +5 -1
  35. package/dist/manifest.d.ts.map +1 -1
  36. package/dist/manifest.js +22 -4
  37. package/dist/manifest.js.map +1 -1
  38. package/dist/reflection.d.ts +5 -0
  39. package/dist/reflection.d.ts.map +1 -1
  40. package/dist/reflection.js +13 -4
  41. package/dist/reflection.js.map +1 -1
  42. package/dist/replay.d.ts +26 -0
  43. package/dist/replay.d.ts.map +1 -0
  44. package/dist/replay.js +63 -0
  45. package/dist/replay.js.map +1 -0
  46. package/dist/schema/standard.d.ts +2 -0
  47. package/dist/schema/standard.d.ts.map +1 -1
  48. package/dist/schema/standard.js +13 -0
  49. package/dist/schema/standard.js.map +1 -1
  50. package/dist/server/body.d.ts +49 -6
  51. package/dist/server/body.d.ts.map +1 -1
  52. package/dist/server/body.js +226 -8
  53. package/dist/server/body.js.map +1 -1
  54. package/dist/server/context.d.ts +20 -3
  55. package/dist/server/context.d.ts.map +1 -1
  56. package/dist/server/contract.d.ts +15 -3
  57. package/dist/server/contract.d.ts.map +1 -1
  58. package/dist/server/contract.js +22 -0
  59. package/dist/server/contract.js.map +1 -1
  60. package/dist/server/cookies.d.ts +26 -5
  61. package/dist/server/cookies.d.ts.map +1 -1
  62. package/dist/server/cookies.js +76 -9
  63. package/dist/server/cookies.js.map +1 -1
  64. package/dist/server/edge.d.ts.map +1 -1
  65. package/dist/server/edge.js +11 -0
  66. package/dist/server/edge.js.map +1 -1
  67. package/dist/server/header-case.d.ts +9 -0
  68. package/dist/server/header-case.d.ts.map +1 -0
  69. package/dist/server/header-case.js +54 -0
  70. package/dist/server/header-case.js.map +1 -0
  71. package/dist/server/headers.d.ts +8 -0
  72. package/dist/server/headers.d.ts.map +1 -0
  73. package/dist/server/headers.js +13 -0
  74. package/dist/server/headers.js.map +1 -0
  75. package/dist/server/node-outcome-hook.d.ts.map +1 -1
  76. package/dist/server/node-outcome-hook.js +7 -11
  77. package/dist/server/node-outcome-hook.js.map +1 -1
  78. package/dist/server/node-outcome.d.ts +8 -1
  79. package/dist/server/node-outcome.d.ts.map +1 -1
  80. package/dist/server/node-outcome.js +10 -3
  81. package/dist/server/node-outcome.js.map +1 -1
  82. package/dist/server/plugin.d.ts +66 -7
  83. package/dist/server/plugin.d.ts.map +1 -1
  84. package/dist/server/plugin.js +14 -10
  85. package/dist/server/plugin.js.map +1 -1
  86. package/dist/server/proto-guard.d.ts +30 -0
  87. package/dist/server/proto-guard.d.ts.map +1 -0
  88. package/dist/server/proto-guard.js +116 -0
  89. package/dist/server/proto-guard.js.map +1 -0
  90. package/dist/server/registry.d.ts +6 -2
  91. package/dist/server/registry.d.ts.map +1 -1
  92. package/dist/server/request-context.d.ts +24 -6
  93. package/dist/server/request-context.d.ts.map +1 -1
  94. package/dist/server/request-context.js +157 -30
  95. package/dist/server/request-context.js.map +1 -1
  96. package/dist/server/respond.d.ts +2 -2
  97. package/dist/server/respond.d.ts.map +1 -1
  98. package/dist/server/respond.js +34 -8
  99. package/dist/server/respond.js.map +1 -1
  100. package/dist/server/response-contract-lane.d.ts +1 -1
  101. package/dist/server/response-contract-lane.d.ts.map +1 -1
  102. package/dist/server/response-contract-lane.js +45 -13
  103. package/dist/server/response-contract-lane.js.map +1 -1
  104. package/dist/server/response-hooks.d.ts +10 -0
  105. package/dist/server/response-hooks.d.ts.map +1 -0
  106. package/dist/server/response-hooks.js +52 -0
  107. package/dist/server/response-hooks.js.map +1 -0
  108. package/dist/server/runtime-core.d.ts +12 -0
  109. package/dist/server/runtime-core.d.ts.map +1 -1
  110. package/dist/server/runtime-core.js +8 -0
  111. package/dist/server/runtime-core.js.map +1 -1
  112. package/dist/server/server-types.d.ts +60 -6
  113. package/dist/server/server-types.d.ts.map +1 -1
  114. package/dist/server/server.d.ts +80 -23
  115. package/dist/server/server.d.ts.map +1 -1
  116. package/dist/server/server.js +429 -213
  117. package/dist/server/server.js.map +1 -1
  118. package/dist/server/static-headers.d.ts +14 -3
  119. package/dist/server/static-headers.d.ts.map +1 -1
  120. package/dist/server/static-headers.js +28 -6
  121. package/dist/server/static-headers.js.map +1 -1
  122. package/dist/server/transport-lane.d.ts +8 -0
  123. package/dist/server/transport-lane.d.ts.map +1 -1
  124. package/dist/server/transport-lane.js +12 -8
  125. package/dist/server/transport-lane.js.map +1 -1
  126. package/dist/server/websocket.d.ts +3 -0
  127. package/dist/server/websocket.d.ts.map +1 -1
  128. package/dist/server/websocket.js +14 -0
  129. package/dist/server/websocket.js.map +1 -1
  130. package/dist/server.d.ts +2 -2
  131. package/dist/server.d.ts.map +1 -1
  132. package/dist/server.js +2 -2
  133. package/dist/server.js.map +1 -1
  134. package/dist/tool-catalog.d.ts +21 -0
  135. package/dist/tool-catalog.d.ts.map +1 -0
  136. package/dist/tool-catalog.js +16 -0
  137. package/dist/tool-catalog.js.map +1 -0
  138. package/dist/tool-contract.d.ts +238 -0
  139. package/dist/tool-contract.d.ts.map +1 -0
  140. package/dist/tool-contract.js +569 -0
  141. package/dist/tool-contract.js.map +1 -0
  142. package/package.json +26 -1
  143. package/src/assurance.ts +236 -6
  144. package/src/capabilities.ts +14 -1
  145. package/src/diff.ts +5 -2
  146. package/src/errors.ts +1 -0
  147. package/src/evidence.ts +319 -0
  148. package/src/execution-policy.ts +57 -0
  149. package/src/index.ts +1 -1
  150. package/src/internal/route-assurance.ts +67 -4
  151. package/src/internal/route-execution.ts +276 -1
  152. package/src/manifest.ts +27 -5
  153. package/src/reflection.ts +24 -4
  154. package/src/replay.ts +96 -0
  155. package/src/schema/standard.ts +16 -0
  156. package/src/server/body.ts +275 -7
  157. package/src/server/context.ts +23 -3
  158. package/src/server/contract.ts +43 -3
  159. package/src/server/cookies.ts +80 -10
  160. package/src/server/edge.ts +10 -0
  161. package/src/server/header-case.ts +54 -0
  162. package/src/server/headers.ts +11 -0
  163. package/src/server/node-outcome-hook.ts +6 -11
  164. package/src/server/node-outcome.ts +10 -2
  165. package/src/server/plugin.ts +87 -8
  166. package/src/server/proto-guard.ts +118 -0
  167. package/src/server/registry.ts +6 -2
  168. package/src/server/request-context.ts +242 -26
  169. package/src/server/respond.ts +35 -4
  170. package/src/server/response-contract-lane.ts +6 -17
  171. package/src/server/response-hooks.ts +51 -0
  172. package/src/server/runtime-core.ts +14 -0
  173. package/src/server/server-types.ts +66 -6
  174. package/src/server/server.ts +672 -287
  175. package/src/server/static-headers.ts +27 -3
  176. package/src/server/transport-lane.ts +19 -8
  177. package/src/server/websocket.ts +18 -2
  178. package/src/server.ts +7 -0
  179. package/src/tool-catalog.ts +40 -0
  180. package/src/tool-contract.ts +896 -0
@@ -8,6 +8,8 @@ import {
8
8
  } from "../budget.ts"
9
9
  import type { EffectLifecycleObserver } from "../effect-lifecycle.ts"
10
10
  import { FrameworkError, RouteConfigError } from "../errors.ts"
11
+ // Type-only (erased at build): the package root re-exports this module, so a value import would cycle.
12
+ import type { Version } from "../index.ts"
11
13
  import {
12
14
  type AroundCapabilityOptions,
13
15
  CAPABILITY_GUARD,
@@ -26,22 +28,19 @@ import {
26
28
  validEvidenceId,
27
29
  } from "../internal/route-assurance.ts"
28
30
  import { type CatalogRoute, RouteCatalog } from "../internal/route-catalog.ts"
29
- import type {
30
- ContextRouteRunner,
31
- FusedBodyRunner,
32
- FusedWebRunner,
33
- InternalHandler,
34
- RawAfterHandle,
35
- RawAround,
36
- RawBeforeHandle,
37
- RawDerive,
38
- RawErrorHandler,
39
- RouteEntry,
40
- RouteExecutionPlan,
41
- RouteExecutionRunner,
31
+ import {
32
+ compileRouteExecutionPlan,
33
+ type FusedBodyRunner,
34
+ type FusedWebRunner,
35
+ type InternalHandler,
36
+ type RawAfterHandle,
37
+ type RawAround,
38
+ type RawBeforeHandle,
39
+ type RawDerive,
40
+ type RawErrorHandler,
41
+ type RouteEntry,
42
42
  } from "../internal/route-execution.ts"
43
43
  import { isSameOriginRequest } from "../internal/same-origin.ts"
44
- import type { RequestLedger } from "../ledger.ts"
45
44
  import { compileRoutePattern, decodeRouteParams } from "../router/pattern.ts"
46
45
  import { EMPTY_PARAMS, type Method, Router } from "../router/router.ts"
47
46
  import type {
@@ -50,9 +49,15 @@ import type {
50
49
  StandardResult,
51
50
  StandardSchemaV1,
52
51
  } from "../schema/standard.ts"
53
- import { assertByteLimit, parseContentLength } from "./body.ts"
52
+ import { assertByteLimit, markTransportCap, markTrustedBodyFraming } from "./body.ts"
54
53
  import { type ClientIpTrust, resolveClientIp } from "./client-ip.ts"
55
54
  import type { Context, Platform, ResponseControls, RouteSchema } from "./context.ts"
55
+ import {
56
+ hasLowercaseHeaderKeysMark,
57
+ headerKeysAllLowercase,
58
+ markLowercaseHeaderKeys,
59
+ } from "./header-case.ts"
60
+ import { headerObjectOf } from "./headers.ts"
56
61
  import { jsonError, pathnameOf, type UrlParts, urlPartsOf } from "./http.ts"
57
62
  import { type NodeServeOutcome, withStaticNodeHeaders } from "./node-outcome.ts"
58
63
  import {
@@ -81,15 +86,14 @@ import {
81
86
  fusedRespond,
82
87
  fusedRespondNoSet,
83
88
  knownMutableHeaders,
84
- markTaggedResponse,
85
89
  rememberMutableHeaders,
86
90
  taggedResponseBody,
87
- taggedResponseOwner,
88
91
  toResponse,
89
92
  } from "./respond.ts"
90
93
  // Type-only: erased, so the kernel never pulls the lane's implementation into a bundle that does not
91
94
  // install the plugin. The value side arrives through the symbol-keyed install seam.
92
95
  import type { ResponseContractRuntime } from "./response-contract-lane.ts"
96
+ import { applyBodyReplacement, withReplacedBody } from "./response-hooks.ts"
93
97
  import {
94
98
  CONTEXT_SEARCH,
95
99
  CONTEXT_SET,
@@ -116,56 +120,6 @@ export type {
116
120
  ResponseHeadersView,
117
121
  }
118
122
 
119
- function isBodylessStatus(status: number): boolean {
120
- return status === 204 || status === 205 || status === 304
121
- }
122
-
123
- /** Apply a body hook's return to the native response context (bytes, or a structured replacement). */
124
- function applyBodyReplacement(
125
- response: NodeResponseContext,
126
- replaced: string | Uint8Array | ResponseBodyReplacement | undefined,
127
- ): void {
128
- if (replaced === undefined) return
129
- if (typeof replaced === "string" || replaced instanceof Uint8Array) {
130
- response.body = isBodylessStatus(response.status) ? null : replaced
131
- return
132
- }
133
- if (replaced.body !== undefined) response.body = replaced.body
134
- if (replaced.status !== undefined) response.status = replaced.status
135
- if (isBodylessStatus(response.status)) response.body = null
136
- }
137
-
138
- /** Swap a tagged Response's body for a hook's replacement, re-tagging so later body hooks (and the
139
- * Node fallback's direct writer) see the new bytes; explicit lengths are dropped so framing is
140
- * re-derived from what actually ships. */
141
- function withReplacedBody(
142
- response: Response,
143
- replaced: string | Uint8Array | ResponseBodyReplacement | undefined,
144
- ): Response {
145
- if (replaced === undefined) return response
146
- const originalBody = taggedResponseBody(response)
147
- let body: string | Uint8Array | null
148
- let status = response.status
149
- if (typeof replaced === "string" || replaced instanceof Uint8Array) {
150
- body = replaced
151
- } else {
152
- body = replaced.body !== undefined ? replaced.body : (originalBody ?? null)
153
- if (replaced.status !== undefined) status = replaced.status
154
- if (body === (originalBody ?? null) && status === response.status) {
155
- return response
156
- }
157
- }
158
- if (isBodylessStatus(status)) body = null
159
- const headers = new Headers(response.headers)
160
- headers.delete("content-length")
161
- const next = new Response(body as ConstructorParameters<typeof Response>[0], {
162
- status,
163
- headers,
164
- })
165
- if (body !== null) markTaggedResponse(next, body, taggedResponseOwner(response))
166
- return next
167
- }
168
-
169
123
  const HEADER_MUTABILITY_PROBE = "x-nifra-header-probe"
170
124
  const GUARDED_RESPONSE_HEADERS = new WeakSet<Headers>()
171
125
 
@@ -269,7 +223,7 @@ import {
269
223
  import type { EffectLedgerRuntime, ResolvedEffectLedger } from "./ledger-lane.ts"
270
224
  import { jsonLogger, type Logger } from "./logger.ts"
271
225
  import type { McpRuntime } from "./mcp-hook.ts"
272
- import type { IdentityPlugin } from "./plugin.ts"
226
+ import type { ContextPlugin, IdentityPlugin } from "./plugin.ts"
273
227
  import type {
274
228
  AddRoute,
275
229
  EmptyRegistry,
@@ -281,15 +235,18 @@ import type {
281
235
  import type {
282
236
  AdmissionController,
283
237
  AdmissionDecision,
238
+ FetchHandler,
284
239
  McpPromptDescriptor,
285
240
  McpResourceDescriptor,
286
241
  Middleware,
242
+ MountFetchOptions,
287
243
  PromptArgument,
288
244
  PromptMessage,
289
245
  ResponseFinalization,
290
246
  RouteDescriptor,
291
247
  RunningServer,
292
248
  ServerOptions,
249
+ StopHook,
293
250
  ToolAnnotations,
294
251
  } from "./server-types.ts"
295
252
  import type { SSEInit, TypedSSEStream } from "./sse.ts"
@@ -323,6 +280,8 @@ export interface RequestSource {
323
280
  readonly body: ReadableStream<Uint8Array> | null
324
281
  arrayBuffer(): Promise<ArrayBuffer>
325
282
  json(): Promise<unknown>
283
+ /** Optional runtime-native exact-byte reader (Bun's Request.bytes, when available). */
284
+ bytes?(): Promise<Uint8Array>
326
285
  /** Present only when materializing a `Request` is non-trivial (the Node lazy source); for a real
327
286
  * `Request` passed as the source it's absent and {@link requestOf} returns the source itself. */
328
287
  readonly request?: Request
@@ -352,6 +311,7 @@ export interface RawContext {
352
311
  // Writable: the lifecycle replaces it with the validated/coerced value when a `params` schema is
353
312
  // declared (handlers still see it `readonly` via the public `Context` interface).
354
313
  params: Record<string, string>
314
+ headers: Record<string, string>
355
315
  query: unknown
356
316
  readonly cookies: Readonly<Record<string, string>>
357
317
  body: unknown
@@ -386,6 +346,14 @@ interface BunUpgradeServer {
386
346
  requestIP(request: Request): { readonly address: string } | null
387
347
  }
388
348
 
349
+ type MountedFetchHandler = (request: Request, platform?: Platform) => MaybePromise<Response>
350
+
351
+ interface FetchMount {
352
+ readonly path: string
353
+ readonly handler: MountedFetchHandler
354
+ readonly stripPrefix: boolean
355
+ }
356
+
389
357
  /** The socket peer Bun observed, as a `Platform` for the request lifecycle (`undefined` if unknown).
390
358
  * Typed structurally on `requestIP` alone so any Bun `Server` (WS or not) satisfies it. */
391
359
  function bunPeerPlatform(
@@ -470,15 +438,18 @@ export type Handler<
470
438
  export type {
471
439
  AdmissionController,
472
440
  AdmissionDecision,
441
+ FetchHandler,
473
442
  McpPromptDescriptor,
474
443
  McpResourceDescriptor,
475
444
  Middleware,
445
+ MountFetchOptions,
476
446
  PromptArgument,
477
447
  PromptMessage,
478
448
  ResponseFinalization,
479
449
  RouteDescriptor,
480
450
  RunningServer,
481
451
  ServerOptions,
452
+ StopHook,
482
453
  ToolAnnotations,
483
454
  }
484
455
 
@@ -490,16 +461,21 @@ export type AnyServer = Server<any, any>
490
461
  // Plugin definers + their types now live in `./plugin.ts`; re-exported here so `.use()` callers and
491
462
  // existing importers keep resolving them from the server module.
492
463
  export {
464
+ type ContextPlugin,
465
+ type DefinePluginResult,
466
+ defineContextPlugin,
493
467
  defineIdentityPlugin,
494
468
  definePlugin,
495
469
  defineRouterPlugin,
496
470
  type NifraPlugin,
471
+ type PluginTypeCollapsed,
497
472
  } from "./plugin.ts"
498
473
  export type { IdentityPlugin }
499
474
 
500
475
  const DEFAULT_MAX_BODY_BYTES = 1_000_000
501
476
  const DEFAULT_DRAIN_MS = 10_000
502
477
  const DRAIN_POLL_MS = 10
478
+ const STOP_HOOK_TIMEOUT_MS = 5_000
503
479
 
504
480
  /** Same-origin check for a WebSocket handshake (CSWSH default). {@link isSameOriginRequest} is the one
505
481
  * owner, shared with the server-function mount in `@nifrajs/web` - the two used to answer differently
@@ -528,6 +504,32 @@ function hasReplacementParam(params: Record<string, string>): boolean {
528
504
  return false
529
505
  }
530
506
 
507
+ function normalizeMountPrefix(path: string): string {
508
+ if (!path.startsWith("/") || path.includes("?") || path.includes("#")) {
509
+ throw new TypeError("mountFetch path must be an absolute pathname without a query or hash")
510
+ }
511
+ const withoutWildcard = path.endsWith("/*") ? path.slice(0, -2) : path
512
+ if (withoutWildcard.includes("*") || withoutWildcard.includes(":")) {
513
+ throw new TypeError("mountFetch path must be a literal prefix, optionally ending in /*")
514
+ }
515
+ if (withoutWildcard.length === 0) return "/"
516
+ return withoutWildcard.length > 1 && withoutWildcard.endsWith("/")
517
+ ? withoutWildcard.slice(0, -1)
518
+ : withoutWildcard
519
+ }
520
+
521
+ function underMountPrefix(pathname: string, prefix: string): boolean {
522
+ return prefix === "/" || pathname === prefix || pathname.startsWith(`${prefix}/`)
523
+ }
524
+
525
+ function stripMountPrefix(request: Request, prefix: string): Request {
526
+ if (prefix === "/") return request
527
+ const url = new URL(request.url)
528
+ const rest = url.pathname.slice(prefix.length)
529
+ url.pathname = rest === "" ? "/" : rest
530
+ return new Request(url.href, request)
531
+ }
532
+
531
533
  /** `ctx.set` carrying the lazy backings (`_headers`, `_cookies`) so `toResponse` can skip allocating
532
534
  * anything when no handler touched `c.set.*`. Server-internal. */
533
535
  export type CtxSet = ResponseControls & {
@@ -549,6 +551,17 @@ function isContextlessNoArgArrow(handler: (context: never) => unknown): boolean
549
551
  }
550
552
  }
551
553
 
554
+ /** `major.minor` of a full version literal - patch releases never change the typed surface. */
555
+ type FeatureVersionOf<V extends string> = V extends `${infer Major}.${infer Minor}.${string}`
556
+ ? `${Major}.${Minor}`
557
+ : V
558
+
559
+ /**
560
+ * The feature version of the `@nifrajs/core` copy that declared these types, derived from the single
561
+ * `VERSION` literal (type-only import - erased, so no runtime cycle with the package root).
562
+ */
563
+ export type NifraFeatureVersion = FeatureVersionOf<Version>
564
+
552
565
  // Stable module-level finalizers so `fetch`/`resolveNode` allocate no per-request closures.
553
566
  const IDENTITY_RESPONSE = (response: Response): Response => response
554
567
  const RESPONSE_TIMEOUT = (): Response => jsonError(503, "request_timeout")
@@ -562,6 +575,16 @@ const RESPONSE_TIMEOUT = (): Response => jsonError(503, "request_timeout")
562
575
  * .get("/me", (c) => c.user) // c.user + c.db are typed
563
576
  */
564
577
  export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
578
+ /**
579
+ * Which copy of `@nifrajs/core` declared this type. Two copies in one build (a linked sibling repo,
580
+ * a hoisting split) produce two unrelated `Server` types whose assignment error is a wall of
581
+ * missing internals; the brand puts the feature version in the type itself, so `typeof app` on
582
+ * hover - and `NifraFeatureVersion` in an assertion - says which core an app is talking to.
583
+ * `nifra doctor` is the diagnosis that names both install paths.
584
+ *
585
+ * `declare` = type-only: no field is emitted, nothing is allocated per server.
586
+ */
587
+ declare readonly __nifraCoreVersion: NifraFeatureVersion
565
588
  private readonly catalog: RouteCatalog
566
589
  /** WebSocket routes, matched separately at upgrade time (a GET + `Upgrade: websocket`). */
567
590
  private readonly wsRouter: Router<WsEntry>
@@ -571,6 +594,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
571
594
  * no-WebSocket app never constructs (or bundles) it. */
572
595
  private topics: TopicRegistry | undefined
573
596
  private readonly maxBodyBytes: number
597
+ private readonly protoPoisoning: "reject" | "strip" | "ignore"
598
+ /** `trustBodyFraming`: mark every `app.fetch` request as runtime-framed (see ServerOptions). */
599
+ private readonly trustBodyFraming: boolean
574
600
  private readonly wsMaxPayloadBytes: number
575
601
  private readonly requestTimeoutMs: number
576
602
  /** Installed by the `responseContract()` plugin; `undefined` = not installed, which is the default
@@ -582,6 +608,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
582
608
  private readonly maxInboundDeadlineMs: number
583
609
  private readonly deadlineAdmissionOptions: DeadlineAdmissionOptions
584
610
  private readonly gracefulSignals: boolean
611
+ private readonly stopHooks: StopHook[]
612
+ private readonly fetchMounts: FetchMount[]
585
613
  /** Capacity-admission gate; `undefined` = off (the request path pays nothing). */
586
614
  private readonly capacityGate: AdmissionController | undefined
587
615
  private readonly onCapabilityUse: ((event: CapabilityUseEvent) => void) | undefined
@@ -602,6 +630,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
602
630
  /** Installed WebSocket runtime for `.ws()` routes; `undefined` until `.use(websocket())`. */
603
631
  private wsRuntime: WsRuntime | undefined
604
632
  private readonly logger: Logger
633
+ /** How much of an unhandled error {@link logRequestError} records. See `ServerOptions.errorLogDetail`. */
634
+ private readonly errorLogDetail: "full" | "message" | "none"
605
635
  /** App-wide validation-error fallback; a route's own `schema.onValidationError` takes precedence. */
606
636
  private readonly defaultOnValidationError?: RouteSchema["onValidationError"]
607
637
  private bunServer: RunningServer | undefined
@@ -620,6 +650,13 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
620
650
  private readonly onNodeResponseHooks: Array<NodeResponseHook | undefined>
621
651
  /** Registration-time eligibility for the Node response twin lane. */
622
652
  private nodeResponseHooksComplete: boolean
653
+ /**
654
+ * True once a bundle registers a RAW `onNodeResponse` twin - one handed the outcome's header
655
+ * record itself rather than the case-normalizing view. Their names are contractually the wire
656
+ * spelling, but registration cannot PROVE it, so such an app forgoes the all-lowercase mark and
657
+ * every reader re-derives the answer, exactly as before the mark existed.
658
+ */
659
+ private hasRawNodeResponseHook: boolean
623
660
  private readonly onResponseFinalizedHooks: RawOnResponseFinalized[]
624
661
  /**
625
662
  * Statically declared response headers, merged and prebuilt at registration; `undefined` (the
@@ -658,6 +695,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
658
695
  this.topics = undefined
659
696
  const maxBodyBytes = options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES
660
697
  assertByteLimit(maxBodyBytes, "maxBodyBytes")
698
+ this.protoPoisoning = options.protoPoisoning ?? "reject"
699
+ this.trustBodyFraming = options.trustBodyFraming ?? false
661
700
  const wsMaxPayloadBytes = options.wsMaxPayloadBytes ?? maxBodyBytes
662
701
  assertByteLimit(wsMaxPayloadBytes, "wsMaxPayloadBytes")
663
702
  this.maxBodyBytes = maxBodyBytes
@@ -677,6 +716,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
677
716
  throw new RangeError("maxInboundDeadlineMs must be a finite positive number")
678
717
  }
679
718
  this.gracefulSignals = options.gracefulSignals ?? false
719
+ this.stopHooks = []
720
+ this.fetchMounts = []
680
721
  this.capacityGate = options.admission
681
722
  this.onCapabilityUse = options.onCapabilityUse
682
723
  this.capabilityInterceptors = []
@@ -689,6 +730,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
689
730
  this.responseContractRuntime = undefined
690
731
  this.idempotencyRuntime = undefined
691
732
  this.logger = options.logger ?? jsonLogger()
733
+ this.errorLogDetail = options.errorLogDetail ?? "full"
692
734
  this.defaultOnValidationError = options.onValidationError
693
735
  this.bunServer = undefined
694
736
  this.sealed = false
@@ -704,6 +746,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
704
746
  this.onResponseHooks = []
705
747
  this.onNodeResponseHooks = []
706
748
  this.nodeResponseHooksComplete = true
749
+ this.hasRawNodeResponseHook = false
707
750
  this.onResponseFinalizedHooks = []
708
751
  this.staticResponseHeaders = undefined
709
752
  this.wrapWebResponse = IDENTITY_RESPONSE
@@ -729,6 +772,36 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
729
772
  }
730
773
  }
731
774
 
775
+ /** Register a callback awaited after graceful drain and server stop. Hung callbacks are bounded. */
776
+ onStop(fn: StopHook): this {
777
+ this.assertConfigurable("onStop()")
778
+ if (typeof fn !== "function") throw new TypeError("onStop callback must be a function")
779
+ this.stopHooks.push(fn)
780
+ return this
781
+ }
782
+
783
+ /**
784
+ * Mount a legacy fetch handler under a literal path prefix. Mounted handlers sit behind typed
785
+ * routes, so routes can move one at a time while the remaining legacy surface stays live.
786
+ * Mounted responses are outside Nifra's typed route and response-contract checks.
787
+ */
788
+ mountFetch(
789
+ path: string,
790
+ handler: FetchHandler<EnvOf<Ctx>>,
791
+ options: MountFetchOptions = {},
792
+ ): this {
793
+ this.assertConfigurable("mountFetch()")
794
+ if (typeof handler !== "function") throw new TypeError("mountFetch handler must be a function")
795
+ const mount: FetchMount = {
796
+ path: normalizeMountPrefix(path),
797
+ handler: handler as MountedFetchHandler,
798
+ stripPrefix: options.stripPrefix === true,
799
+ }
800
+ this.fetchMounts.push(mount)
801
+ this.fetchMounts.sort((a, b) => b.path.length - a.path.length)
802
+ return this
803
+ }
804
+
732
805
  /** Add a per-request, computed context extension for subsequent routes. */
733
806
  derive<D extends object>(fn: (context: Context & Ctx) => MaybePromise<D>): Server<R, Ctx & D> {
734
807
  this.assertConfigurable("derive()")
@@ -963,6 +1036,14 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
963
1036
  * defeat the generic inference of the transforming overload below and collapse the result to `any`.
964
1037
  */
965
1038
  use(plugin: IdentityPlugin): this
1039
+ /**
1040
+ * Apply a **context** plugin ({@link ContextPlugin}, from {@link defineContextPlugin}) - it adds `D`
1041
+ * to the handler context and changes nothing else, so the route registry `R` and the existing context
1042
+ * `Ctx` thread through untouched. Like the identity overload above, this exists because the generic
1043
+ * `(app: this) => Out` overload below cannot infer through a *generic* plugin signature: it erases the
1044
+ * plugin's own type parameters to their constraints, which silently widens `R` to `Registry`.
1045
+ */
1046
+ use<D extends object>(plugin: ContextPlugin<D>): Server<R, Ctx & D>
966
1047
  /**
967
1048
  * Apply a **plugin function** - `(app) => app`, typically built with {@link definePlugin}. It's
968
1049
  * called with `this` and its result is returned, so an inline plugin's `derive`/`decorate` thread
@@ -1027,6 +1108,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1027
1108
  this.onResponseHooks.push(arg.onResponse)
1028
1109
  this.onNodeResponseHooks.push(arg.onNodeResponse)
1029
1110
  if (arg.onNodeResponse === undefined) this.nodeResponseHooksComplete = false
1111
+ else this.hasRawNodeResponseHook = true
1030
1112
  } else if (arg.onNodeResponse !== undefined) {
1031
1113
  throw new TypeError("onNodeResponse() requires a paired onResponse() hook")
1032
1114
  }
@@ -1383,6 +1465,29 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1383
1465
  handler: (context: never) => unknown,
1384
1466
  ): CatalogRoute {
1385
1467
  const pattern = compileRoutePattern(path)
1468
+ let bodyLimit: number | undefined = this.maxBodyBytes
1469
+ const invalidBodyLimit = (message: string): never => {
1470
+ throw new RouteConfigError("INVALID_BODY_LIMIT", `route ${method} ${path}: ${message}`)
1471
+ }
1472
+ if (schema?.bodyLimitReason !== undefined && schema.bodyLimit !== "unlimited") {
1473
+ invalidBodyLimit('bodyLimitReason is only valid with bodyLimit: "unlimited"')
1474
+ }
1475
+ if (schema?.bodyLimit === "unlimited") {
1476
+ if (
1477
+ typeof schema.bodyLimitReason !== "string" ||
1478
+ schema.bodyLimitReason.trim().length === 0
1479
+ ) {
1480
+ invalidBodyLimit('bodyLimit: "unlimited" requires a non-empty bodyLimitReason')
1481
+ }
1482
+ bodyLimit = undefined
1483
+ } else if (schema?.bodyLimit !== undefined) {
1484
+ try {
1485
+ assertByteLimit(schema.bodyLimit, "route bodyLimit")
1486
+ } catch {
1487
+ invalidBodyLimit('bodyLimit must be a non-negative safe integer or "unlimited"')
1488
+ }
1489
+ bodyLimit = schema.bodyLimit
1490
+ }
1386
1491
  const capabilities = normalizeRouteCapabilities(schema?.capabilities)
1387
1492
  const handlerAssurance = assuranceDeclarationsOf(handler as unknown as object)
1388
1493
  const invalidHandlerScope = handlerAssurance.find(
@@ -1444,6 +1549,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1444
1549
  : undefined
1445
1550
  const bare =
1446
1551
  schema?.params === undefined &&
1552
+ schema?.headers === undefined &&
1447
1553
  schema?.body === undefined &&
1448
1554
  schema?.query === undefined &&
1449
1555
  idempotent === undefined &&
@@ -1465,6 +1571,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1465
1571
  schema?.query !== undefined &&
1466
1572
  schema.body === undefined &&
1467
1573
  schema.params === undefined &&
1574
+ schema.headers === undefined &&
1468
1575
  schema.onValidationError === undefined &&
1469
1576
  this.defaultOnValidationError === undefined &&
1470
1577
  idempotent === undefined &&
@@ -1479,6 +1586,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1479
1586
  schema?.body !== undefined &&
1480
1587
  schema.query === undefined &&
1481
1588
  schema.params === undefined &&
1589
+ schema.headers === undefined &&
1482
1590
  this.derives.length === 0 &&
1483
1591
  this.beforeHandleHooks.length === 0 &&
1484
1592
  this.afterHandleHooks.length === 0 &&
@@ -1499,6 +1607,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1499
1607
  handler as unknown as InternalHandler,
1500
1608
  schema.body as StandardSchemaV1,
1501
1609
  hasDecorations ? routeDecorations : undefined,
1610
+ bodyLimit ?? this.maxBodyBytes,
1502
1611
  )
1503
1612
  : undefined
1504
1613
  const fusedWeb =
@@ -1507,12 +1616,14 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1507
1616
  handler as unknown as InternalHandler,
1508
1617
  hasDecorations ? routeDecorations : undefined,
1509
1618
  isContextlessNoArgArrow(handler),
1619
+ bodyLimit ?? this.maxBodyBytes,
1510
1620
  )
1511
1621
  : fusedQuery
1512
1622
  ? this.buildFusedQueryWeb(
1513
1623
  handler as unknown as InternalHandler,
1514
1624
  hasDecorations ? routeDecorations : undefined,
1515
1625
  schema.query as StandardSchemaV1,
1626
+ bodyLimit ?? this.maxBodyBytes,
1516
1627
  )
1517
1628
  : fusedBody
1518
1629
  ? this.buildFusedBodyWeb(fusedBodyRunner as FusedBodyRunner)
@@ -1528,6 +1639,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1528
1639
  schema?.body === undefined &&
1529
1640
  schema?.query !== undefined &&
1530
1641
  schema.params === undefined &&
1642
+ schema.headers === undefined &&
1531
1643
  this.derives.length === 0 &&
1532
1644
  this.beforeHandleHooks.length === 0 &&
1533
1645
  this.afterHandleHooks.length === 0 &&
@@ -1539,7 +1651,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1539
1651
  // so the request path never re-checks params/body presence. Parameter-schema routes retain the
1540
1652
  // generic lifecycle runner until their more involved recovery matrix is selected explicitly.
1541
1653
  const lifecycleLane =
1542
- lane !== "lifecycle" || schema?.params !== undefined
1654
+ lane !== "lifecycle" || schema?.params !== undefined || schema?.headers !== undefined
1543
1655
  ? undefined
1544
1656
  : schema?.body !== undefined
1545
1657
  ? schema.query !== undefined
@@ -1562,21 +1674,23 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1562
1674
  this.onErrorHooks.length === 0
1563
1675
  ? "derive-before"
1564
1676
  : undefined
1565
- const execution = this.compileExecutionPlan(
1677
+ const execution = compileRouteExecutionPlan({
1566
1678
  lane,
1567
1679
  contextless,
1568
- this.aroundHooks.length > 0,
1569
- ledgered !== undefined,
1680
+ hasAround: this.aroundHooks.length > 0,
1681
+ hasLedger: ledgered !== undefined,
1570
1682
  lifecycleLane,
1571
1683
  fusedWeb,
1572
- fusedBodyRunner,
1573
- fusedWeb === undefined ? undefined : fusedQuery ? "query" : fusedBody ? "body" : "bare",
1574
- )
1684
+ fusedBody: fusedBodyRunner,
1685
+ fusedLane:
1686
+ fusedWeb === undefined ? undefined : fusedQuery ? "query" : fusedBody ? "body" : "bare",
1687
+ })
1575
1688
  const registeredEntry: RouteEntry = {
1576
1689
  // (context: never) => unknown -> InternalHandler: the framework invokes it
1577
1690
  // with the concrete RawContext the typed handler expects, so this is sound.
1578
1691
  handler: handler as unknown as InternalHandler,
1579
1692
  schema,
1693
+ bodyLimit,
1580
1694
  idempotent,
1581
1695
  ledgered,
1582
1696
  responseContract: contracted,
@@ -1598,6 +1712,15 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1598
1712
  ...(schema?.family === true ? { family: true } : {}),
1599
1713
  }
1600
1714
  const routeAssurance: AssuranceDeclaration[] = [...this.activeAssurance, ...handlerAssurance]
1715
+ if (contracted?.runtime.mode === "enforce" && schema?.response !== undefined) {
1716
+ routeAssurance.push(
1717
+ Object.freeze({
1718
+ id: NIFRA_ASSURANCE_IDS.RESPONSE_CONTRACT,
1719
+ source: "response-contract",
1720
+ scope: "plugin",
1721
+ }),
1722
+ )
1723
+ }
1601
1724
  // Inline `schema.assurance`: the route DECLARES its enforcement evidence adjacent to the handler, so an
1602
1725
  // in-handler-guarded route satisfies a policy `require:` clause without a `withRouteAssurance` middleware
1603
1726
  // rewrite. Each id becomes route-scoped `declared` evidence (invalid ids fail closed at registration).
@@ -1607,7 +1730,14 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1607
1730
  `route assurance: invalid evidence id ${JSON.stringify(id)} on ${method} ${path} (use lowercase dot/dash segments)`,
1608
1731
  )
1609
1732
  }
1610
- routeAssurance.push(Object.freeze({ id, source: "declared", scope: "plugin" }))
1733
+ const declared = { id, source: "declared", scope: "plugin" } as AssuranceDeclaration
1734
+ Object.defineProperty(declared, "provenance", {
1735
+ value: "declared",
1736
+ enumerable: false,
1737
+ configurable: false,
1738
+ writable: false,
1739
+ })
1740
+ routeAssurance.push(Object.freeze(declared))
1611
1741
  }
1612
1742
  if (schema?.body !== undefined) {
1613
1743
  routeAssurance.push(
@@ -1639,132 +1769,6 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1639
1769
  }
1640
1770
  }
1641
1771
 
1642
- /** Collapse route-invariant lifecycle decisions into one runner at registration. The request path
1643
- * performs no eligibility ladder: it supplies request state to this already-selected plan. */
1644
- private compileExecutionPlan(
1645
- lane: "bare" | "body" | "query" | "lifecycle",
1646
- contextless: boolean,
1647
- hasAround: boolean,
1648
- hasLedger: boolean,
1649
- lifecycleLane: "hooks" | "query" | "body" | "body-query" | undefined,
1650
- fusedWeb: FusedWebRunner | undefined,
1651
- fusedBody: FusedBodyRunner | undefined,
1652
- fusedLane: "bare" | "body" | "query" | undefined,
1653
- ): RouteExecutionPlan {
1654
- if (contextless) {
1655
- const run: RouteExecutionRunner = (
1656
- runtime,
1657
- entry,
1658
- source,
1659
- params,
1660
- search,
1661
- signal,
1662
- budget,
1663
- platform,
1664
- _nativeContext,
1665
- finalize,
1666
- wrapResponse,
1667
- ) =>
1668
- runtime.runContextlessBare(
1669
- entry,
1670
- source,
1671
- params,
1672
- search,
1673
- signal,
1674
- budget,
1675
- platform,
1676
- finalize,
1677
- wrapResponse,
1678
- )
1679
- return Object.freeze({ run, fusedWeb, fusedBody, fusedLane })
1680
- }
1681
-
1682
- let inner: ContextRouteRunner
1683
- switch (lane) {
1684
- case "bare":
1685
- inner = (runtime, entry, _source, ctx, finalize, wrapResponse) =>
1686
- runtime.runBare(entry, ctx, finalize, wrapResponse)
1687
- break
1688
- case "body":
1689
- inner = (runtime, entry, source, ctx, finalize, wrapResponse) =>
1690
- runtime.runBodyOnly(entry, source, ctx, finalize, wrapResponse)
1691
- break
1692
- case "query":
1693
- inner = (runtime, entry, _source, ctx, finalize, wrapResponse) =>
1694
- runtime.runQueryOnly(entry, ctx, finalize, wrapResponse)
1695
- break
1696
- default:
1697
- switch (lifecycleLane) {
1698
- case "hooks":
1699
- inner = (runtime, entry, _source, ctx, finalize, wrapResponse) =>
1700
- runtime.runLifecycleHooks(entry, ctx, finalize, wrapResponse)
1701
- break
1702
- case "query":
1703
- inner = (runtime, entry, _source, ctx, finalize, wrapResponse) =>
1704
- runtime.runLifecycleQuery(entry, ctx, finalize, wrapResponse)
1705
- break
1706
- case "body":
1707
- inner = (runtime, entry, source, ctx, finalize, wrapResponse) =>
1708
- runtime.runLifecycleBody(entry, source, ctx, finalize, wrapResponse)
1709
- break
1710
- case "body-query":
1711
- inner = (runtime, entry, source, ctx, finalize, wrapResponse) =>
1712
- runtime.runLifecycleBodyQuery(entry, source, ctx, finalize, wrapResponse)
1713
- break
1714
- default:
1715
- inner = (runtime, entry, source, ctx, finalize, wrapResponse) =>
1716
- runtime.runLifecycle(entry, source, ctx, finalize, wrapResponse)
1717
- }
1718
- }
1719
- const execute: ContextRouteRunner = hasAround
1720
- ? (runtime, entry, source, ctx, finalize, wrapResponse) =>
1721
- runtime.runWithAround(
1722
- entry,
1723
- ctx,
1724
- () => inner(runtime, entry, source, ctx, finalize, wrapResponse),
1725
- finalize,
1726
- wrapResponse,
1727
- )
1728
- : inner
1729
- const run: RouteExecutionRunner = (
1730
- runtime,
1731
- entry,
1732
- source,
1733
- params,
1734
- search,
1735
- signal,
1736
- budget,
1737
- platform,
1738
- nativeContext,
1739
- finalize,
1740
- wrapResponse,
1741
- ) => {
1742
- const ctx = nativeContext
1743
- ? RequestContext.native(source, params, search, runtime.maxBodyBytes, platform)
1744
- : new RequestContext(source, params, search, signal, budget, platform, runtime.maxBodyBytes)
1745
- let ledger: RequestLedger | undefined
1746
- // The runtime is always present when a route resolved a ledger (enforced at registration).
1747
- const ledgerRuntime = runtime.effectLedgerRuntime
1748
- if (hasLedger && ledgerRuntime !== undefined) {
1749
- const resolved = entry.ledgered as ResolvedEffectLedger
1750
- ledger = ledgerRuntime.create(resolved)
1751
- ledgerRuntime.attach(ctx, ledger)
1752
- }
1753
- let outcome = execute(runtime, entry, source, ctx, finalize, wrapResponse)
1754
- if (ledger !== undefined && ledgerRuntime !== undefined) {
1755
- const active = ledger
1756
- const resolved = entry.ledgered as ResolvedEffectLedger
1757
- outcome = (outcome instanceof Promise ? outcome : Promise.resolve(outcome)).then((value) =>
1758
- ledgerRuntime.settle(active, resolved, value, (fields) =>
1759
- runtime.logger.error("effect ledger sink failed", fields),
1760
- ),
1761
- )
1762
- }
1763
- return outcome
1764
- }
1765
- return Object.freeze({ run, fusedWeb, fusedBody, fusedLane })
1766
- }
1767
-
1768
1772
  /** The idempotency lane's bridge back into the normal matched lanes, resolved to a concrete Response
1769
1773
  * (the lane buffers the body, then replays it through the route's real validation + handler). */
1770
1774
  private idempotencyRunLanes(
@@ -1837,10 +1841,15 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1837
1841
  *
1838
1842
  * Semantics: merged routes keep the chains captured where they were DEFINED - the group's
1839
1843
  * `derive`/`decorate`/`beforeHandle`/`afterHandle`/`onError`/`around` apply to its routes
1840
- * exactly as they did standalone, so a group wires its own plugins. The group's request-level
1841
- * hooks (`onRequest`/`onResponse`/`onResponseFinalized`) are appended to this server's. This
1842
- * server's route-scoped chains do NOT retroactively wrap merged routes (order-scoped, like
1843
- * routes registered before a `derive`). Fail closed: a path+method collision throws
1844
+ * exactly as they did standalone, so a group wires its own plugins. The same locality rule
1845
+ * covers the group's `onRequest` hooks: they run only for requests that route to the GROUP's
1846
+ * routes (a `bodyLimit()` mounted on an uploads group must not start gating the whole app
1847
+ * because the app composed it in), and the group's global assurance follows its hooks onto
1848
+ * exactly those routes. A group with hooks but NO routes is a middleware bundle - its hooks
1849
+ * can only mean app-wide intent, so they are appended globally, unchanged. Response-side hooks
1850
+ * (`onResponse`/`onResponseFinalized`) are appended to this server's. This server's
1851
+ * route-scoped chains do NOT retroactively wrap merged routes (order-scoped, like routes
1852
+ * registered before a `derive`). Fail closed: a path+method collision throws
1844
1853
  * `RouteConfigError` at merge time, and a group with WebSocket routes is refused (register
1845
1854
  * those on the parent).
1846
1855
  */
@@ -1853,7 +1862,23 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1853
1862
  "merge() does not carry WebSocket routes - register .ws() routes on the parent server",
1854
1863
  )
1855
1864
  }
1856
- this.catalog.addBatch(source.catalog.entries().map((route) => this.bindFusedRuntime(route)))
1865
+ const sourceRoutes = source.catalog.entries()
1866
+ // Hooks and global assurance stay LOCAL to a group that has routes; a route-less group is a
1867
+ // middleware bundle whose hooks can only mean app-wide intent, so it keeps the global append.
1868
+ const scoped = sourceRoutes.length > 0
1869
+ // A group's global assurance rides its (route-scoped) hooks: folded into each merged route's
1870
+ // own evidence rather than the parent's global list, so `routes()` never claims the group's
1871
+ // enforcement for parent routes the group's hooks do not see.
1872
+ const foldedAssurance =
1873
+ scoped && source.globalAssurance.length > 0
1874
+ ? (route: CatalogRoute): CatalogRoute => ({
1875
+ ...route,
1876
+ assurance: [...route.assurance, ...source.globalAssurance],
1877
+ })
1878
+ : (route: CatalogRoute): CatalogRoute => route
1879
+ this.catalog.addBatch(
1880
+ sourceRoutes.map((route) => this.bindFusedRuntime(foldedAssurance(route))),
1881
+ )
1857
1882
  // Resolved idempotency/ledger route entries carry their own store/sink configuration, while the
1858
1883
  // runtime object supplies the generic execution machinery. Preserve a group's installed runtime
1859
1884
  // when the parent has none so merging cannot silently disable a safety lane. If the parent already
@@ -1866,8 +1891,33 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1866
1891
  this.nodeOutcomeRuntime ??= source.nodeOutcomeRuntime
1867
1892
  this.sseRuntime ??= source.sseRuntime
1868
1893
  this.wsRuntime ??= source.wsRuntime
1869
- this.onRequestHooks.push(...source.onRequestHooks)
1870
- this.onNodeRequestHooks.push(...source.onNodeRequestHooks)
1894
+ if (scoped && source.onRequestHooks.length > 0) {
1895
+ // Snapshot the group's routes into a dedicated matcher: the guard must reflect what was
1896
+ // MERGED, not whatever the group's own catalog grows into afterwards. One probe against it
1897
+ // gates each group hook to requests the group would serve; everything else passes untouched.
1898
+ const scope = new RouteCatalog()
1899
+ scope.addBatch(sourceRoutes)
1900
+ this.onRequestHooks.push(
1901
+ ...source.onRequestHooks.map(
1902
+ (hook): RawOnRequest =>
1903
+ (req, platform) =>
1904
+ scope.find(req.method, pathnameOf(req.url)).found ? hook(req, platform) : undefined,
1905
+ ),
1906
+ )
1907
+ // An `undefined` slot marks an unpaired hook (position-aligned with `onRequestHooks`) and
1908
+ // must stay `undefined` - wrapping it would fabricate a Node twin that never existed.
1909
+ this.onNodeRequestHooks.push(
1910
+ ...source.onNodeRequestHooks.map((hook): NodeRequestHook | undefined =>
1911
+ hook === undefined
1912
+ ? undefined
1913
+ : (req, platform) =>
1914
+ scope.find(req.method, pathnameOf(req.url)).found ? hook(req, platform) : undefined,
1915
+ ),
1916
+ )
1917
+ } else {
1918
+ this.onRequestHooks.push(...source.onRequestHooks)
1919
+ this.onNodeRequestHooks.push(...source.onNodeRequestHooks)
1920
+ }
1871
1921
  this.nodeRequestHooksComplete &&= source.nodeRequestHooksComplete
1872
1922
  // The group's static declarations came before its own response hooks, so they are folded in
1873
1923
  // first - and fold themselves into a hook here if this server already has one (same ordering
@@ -1878,13 +1928,14 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1878
1928
  this.onResponseHooks.push(...source.onResponseHooks)
1879
1929
  this.onNodeResponseHooks.push(...source.onNodeResponseHooks)
1880
1930
  this.nodeResponseHooksComplete &&= source.nodeResponseHooksComplete
1931
+ this.hasRawNodeResponseHook ||= source.hasRawNodeResponseHook
1881
1932
  this.onResponseFinalizedHooks.push(...source.onResponseFinalizedHooks)
1882
1933
  if (source.responseBodyTag !== undefined) {
1883
1934
  const owner = this.enableResponseBodyTagging()
1884
1935
  this.responseBodyOwners.add(source.responseBodyTag)
1885
1936
  source.responseBodyOwners.add(owner)
1886
1937
  }
1887
- this.globalAssurance.push(...source.globalAssurance)
1938
+ if (!scoped) this.globalAssurance.push(...source.globalAssurance)
1888
1939
  this.mcpResourceList.push(...source.mcpResourceList)
1889
1940
  this.mcpPromptList.push(...source.mcpPromptList)
1890
1941
  return this as unknown as Server<R & R2, Ctx>
@@ -1903,6 +1954,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1903
1954
  entry.handler,
1904
1955
  entry.schema?.body as StandardSchemaV1,
1905
1956
  entry.hasDecorations ? entry.decorations : undefined,
1957
+ entry.bodyLimit ?? this.maxBodyBytes,
1906
1958
  )
1907
1959
  : undefined
1908
1960
  const fusedWeb =
@@ -1913,11 +1965,13 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1913
1965
  entry.handler,
1914
1966
  entry.hasDecorations ? entry.decorations : undefined,
1915
1967
  entry.schema?.query as StandardSchemaV1,
1968
+ entry.bodyLimit ?? this.maxBodyBytes,
1916
1969
  )
1917
1970
  : this.buildFusedWeb(
1918
1971
  entry.handler,
1919
1972
  entry.hasDecorations ? entry.decorations : undefined,
1920
1973
  isContextlessNoArgArrow(entry.handler),
1974
+ entry.bodyLimit ?? this.maxBodyBytes,
1921
1975
  )
1922
1976
  return {
1923
1977
  ...route,
@@ -1951,6 +2005,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1951
2005
  * Bun/Node/Deno omit it (then `c.env` is `undefined` and `c.waitUntil` runs fire-and-forget).
1952
2006
  */
1953
2007
  fetch(req: Request, platform?: Platform<EnvOf<Ctx>>): MaybePromise<Response> {
2008
+ // An edge deployment whose only ingress is this method declares its requests runtime-framed at
2009
+ // construction; everything else stays on the delivered-byte check (see `trustBodyFraming`).
2010
+ if (this.trustBodyFraming) markTrustedBodyFraming(req)
1954
2011
  // A real `Request` satisfies `RequestSource`, so it's passed straight through - no per-request
1955
2012
  // wrapper allocation on the Web/Bun hot path.
1956
2013
  return this.fetchSource(req, platform)
@@ -2126,12 +2183,16 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2126
2183
  // header or body hook reads them through its view), and on the no-hook path too (which returns
2127
2184
  // the outcome straight to the writer without the finish step below).
2128
2185
  const statics = this.staticResponseHeaders
2186
+ // The fold also publishes the all-lowercase proof, which is why it is worth doing here rather
2187
+ // than in the finish step: it is the only stage the HOOKLESS lane passes through, and that lane
2188
+ // returns straight to the writer below without ever reaching `finishNodeResponse`.
2189
+ const markLowercase = !this.hasRawNodeResponseHook
2129
2190
  const outcome =
2130
2191
  statics === undefined
2131
2192
  ? resolved
2132
2193
  : resolved instanceof Promise
2133
- ? resolved.then((settled) => withStaticNodeHeaders(settled, statics))
2134
- : withStaticNodeHeaders(resolved, statics)
2194
+ ? resolved.then((settled) => withStaticNodeHeaders(settled, statics, markLowercase))
2195
+ : withStaticNodeHeaders(resolved, statics, markLowercase)
2135
2196
  if (webResponseHooks) {
2136
2197
  return outcome instanceof Promise
2137
2198
  ? outcome.then((settled) => this.finishNodeWebResponse(settled, source, runtime))
@@ -2182,13 +2243,24 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2182
2243
  }
2183
2244
 
2184
2245
  let headers = outcome.headers as Record<string, string | readonly string[]> | undefined
2246
+ // ONE pass over the names, here, before any twin runs. The same question is asked three times per
2247
+ // request downstream - the Content-Type lookup just below, the header view's alias index, and the
2248
+ // `@nifrajs/node` writer's normalization gate - and each used to walk the record itself. An app
2249
+ // declaring static headers has already had it answered by the fold in `resolveNode`, for free, so
2250
+ // the pass runs only for the records nothing looked at yet.
2251
+ const lowercaseKeys =
2252
+ headers === undefined ||
2253
+ hasLowercaseHeaderKeysMark(headers) ||
2254
+ headerKeysAllLowercase(headers)
2185
2255
  if (outcome.kind === "json" && outcome.body !== null) {
2186
2256
  // The json render adds its Content-Type at WRITE time, so a body hook checking content types
2187
2257
  // would see nothing. Materialize the writer's own value into the hook-visible record - same
2188
2258
  // string the writer would emit, so the wire is unchanged.
2189
2259
  const hasType =
2190
2260
  headers !== undefined &&
2191
- Object.keys(headers).some((key) => key.toLowerCase() === "content-type")
2261
+ (lowercaseKeys
2262
+ ? headers["content-type"] !== undefined
2263
+ : Object.keys(headers).some((key) => key.toLowerCase() === "content-type"))
2192
2264
  if (!hasType) {
2193
2265
  const defaultContentType = runtime.jsonContentType ?? "application/json;charset=utf-8"
2194
2266
  if (headers === undefined) {
@@ -2202,6 +2274,13 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2202
2274
  }
2203
2275
  }
2204
2276
  }
2277
+ // Publish the proof for the view and the direct writer - but only for an app whose every twin
2278
+ // normalizes case. A raw `onNodeResponse` twin writes the record straight, past the view, so a
2279
+ // mark set before it ran could be made to lie, and a lying mark would ship a mixed-case name that
2280
+ // `Headers` lowercases on every other runtime. Those apps keep the per-reader scans.
2281
+ if (lowercaseKeys && headers !== undefined && !this.hasRawNodeResponseHook) {
2282
+ markLowercaseHeaderKeys(headers)
2283
+ }
2205
2284
  const context: NodeResponseContext = {
2206
2285
  status: outcome.status,
2207
2286
  headers,
@@ -2336,7 +2415,14 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2336
2415
  return { kind: "reject", response: jsonError(403, "forbidden_origin") }
2337
2416
  }
2338
2417
  if (handler.upgrade === undefined) {
2339
- return { kind: "upgrade", handler, data: undefined, pubsub, attach }
2418
+ return {
2419
+ kind: "upgrade",
2420
+ handler,
2421
+ data: undefined,
2422
+ pubsub,
2423
+ attach,
2424
+ maxPayloadBytes: this.wsMaxPayloadBytes,
2425
+ }
2340
2426
  }
2341
2427
  const upgradeSignal = getNeverAbortSignal()
2342
2428
  const ctx = new RequestContext(
@@ -2347,11 +2433,19 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2347
2433
  createUnboundedRequestBudget(upgradeSignal),
2348
2434
  platform,
2349
2435
  this.maxBodyBytes,
2436
+ this.protoPoisoning,
2350
2437
  )
2351
2438
  const settle = (value: unknown): WebSocketUpgradeOutcome =>
2352
2439
  value instanceof Response
2353
2440
  ? { kind: "reject", response: value }
2354
- : { kind: "upgrade", handler, data: value, pubsub, attach }
2441
+ : {
2442
+ kind: "upgrade",
2443
+ handler,
2444
+ data: value,
2445
+ pubsub,
2446
+ attach,
2447
+ maxPayloadBytes: this.wsMaxPayloadBytes,
2448
+ }
2355
2449
  try {
2356
2450
  const result = handler.upgrade(ctx as unknown as WebSocketContext<EnvOf<Ctx>>)
2357
2451
  return result instanceof Promise
@@ -2680,6 +2774,12 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2680
2774
  }
2681
2775
  const match = this.catalog.find(source.method, pathname)
2682
2776
  if (!match.found) {
2777
+ const mounted = this.fetchMount(pathname, source, platform)
2778
+ if (mounted !== undefined) {
2779
+ return mounted instanceof Promise
2780
+ ? mounted.then((response) => wrapResponse(response))
2781
+ : wrapResponse(mounted)
2782
+ }
2683
2783
  if (match.reason === "method-not-allowed") {
2684
2784
  return wrapResponse(
2685
2785
  jsonError(405, "method_not_allowed", { Allow: match.allowed.join(", ") }),
@@ -2708,6 +2808,22 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2708
2808
  )
2709
2809
  }
2710
2810
 
2811
+ private fetchMount(
2812
+ pathname: string,
2813
+ source: RequestSource,
2814
+ platform: Platform | undefined,
2815
+ ): MaybePromise<Response> | undefined {
2816
+ for (const mount of this.fetchMounts) {
2817
+ if (!underMountPrefix(pathname, mount.path)) continue
2818
+ const request = requestOf(source)
2819
+ return mount.handler(
2820
+ mount.stripPrefix ? stripMountPrefix(request, mount.path) : request,
2821
+ platform,
2822
+ )
2823
+ }
2824
+ return undefined
2825
+ }
2826
+
2711
2827
  /** Run a route that has already been matched by the runtime or Nifra's portable router. */
2712
2828
  private runMatched<T>(
2713
2829
  source: RequestSource,
@@ -2720,6 +2836,20 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2720
2836
  onTimeout: () => T,
2721
2837
  webFast: boolean,
2722
2838
  ): MaybePromise<T> {
2839
+ // The route's transport byte cap: one property write here; the capped shadowing of direct
2840
+ // `c.req` body readers happens lazily at `c.req` access (`applyTransportCap`), so a route that
2841
+ // never direct-reads pays nothing. Framework readers bypass the shadow (`rawBodySourceOf`) and
2842
+ // keep their own caps, so `c.boundedBody(explicit)` still overrides upward.
2843
+ // `bodyLimit: "unlimited"` (undefined here) skips the cap entirely.
2844
+ // A schema route consumes and validates the body through `readBodyInput` before any derive or
2845
+ // handler can reach `c.req`; that lane already enforces `entry.bodyLimit`. Installing the full
2846
+ // direct-reader cap on `c.req` here would allocate bound readers, closures, and a stream wrapper
2847
+ // for a body that is already consumed. Keep the lazy transport cap for raw-body routes, where a
2848
+ // user read is the only framework-owned body boundary.
2849
+ if (entry.bodyLimit !== undefined && entry.schema?.body === undefined) {
2850
+ const method = source.method
2851
+ if (method !== "GET" && method !== "HEAD") markTransportCap(source, entry.bodyLimit)
2852
+ }
2723
2853
  // An idempotency route runs its dedupe lane first; on a fresh key it delegates to the normal lanes
2724
2854
  // (with the body buffered). All non-idempotent routes skip straight to the lanes - no added cost.
2725
2855
  // The runtime is always present when a route resolved idempotency (enforced at registration).
@@ -2733,7 +2863,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2733
2863
  search,
2734
2864
  wrapResponse,
2735
2865
  {
2736
- maxBodyBytes: this.maxBodyBytes,
2866
+ maxBodyBytes: entry.bodyLimit ?? this.maxBodyBytes,
2737
2867
  runLanes: (buffered, plat, ent, prm, srch) =>
2738
2868
  this.idempotencyRunLanes(buffered, plat, ent as RouteEntry, prm, srch),
2739
2869
  },
@@ -2861,6 +2991,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2861
2991
 
2862
2992
  /** The narrowest bare route: a syntactic `() => ...` handler cannot observe the context argument, so
2863
2993
  * successful requests can skip allocating `RequestContext`. Errors still allocate one for logging. */
2994
+ // @ts-expect-error TS6133 -- invoked structurally by compiled plans (internal/route-execution.ts)
2995
+ // biome-ignore lint/correctness/noUnusedPrivateClassMembers: invoked structurally by compiled plans (internal/route-execution.ts)
2864
2996
  private runContextlessBare<T>(
2865
2997
  entry: RouteEntry,
2866
2998
  source: RequestSource,
@@ -2925,6 +3057,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2925
3057
  budget,
2926
3058
  platform,
2927
3059
  this.maxBodyBytes,
3060
+ this.protoPoisoning,
2928
3061
  )
2929
3062
  this.logRequestError(err, ctx)
2930
3063
  return wrapResponse(jsonError(500, "internal_error"))
@@ -2938,6 +3071,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2938
3071
  * (a thrown `Response` is control flow; anything else is a logged flat 500). This is where nifra skips
2939
3072
  * the per-request async-frame tax - the same win codegen routers get, but without `eval`.
2940
3073
  */
3074
+ // @ts-expect-error TS6133 -- invoked structurally by compiled plans (internal/route-execution.ts)
3075
+ // biome-ignore lint/correctness/noUnusedPrivateClassMembers: invoked structurally by compiled plans (internal/route-execution.ts)
2941
3076
  private runBare<T>(
2942
3077
  entry: RouteEntry,
2943
3078
  ctx: RawContext,
@@ -2974,6 +3109,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2974
3109
  handler: InternalHandler,
2975
3110
  decorations: Record<PropertyKey, unknown> | undefined,
2976
3111
  contextless: boolean,
3112
+ maxBodyBytes: number,
2977
3113
  ): FusedWebRunner {
2978
3114
  // The fused lanes return to the runtime directly, so the framework's own error renders fold in
2979
3115
  // static headers here rather than through the shared `wrapResponse` seam.
@@ -2994,7 +3130,14 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2994
3130
  return logError(
2995
3131
  err,
2996
3132
  nativeContext
2997
- ? RequestContext.native(source, params, search, this.maxBodyBytes, platform)
3133
+ ? RequestContext.native(
3134
+ source,
3135
+ params,
3136
+ search,
3137
+ maxBodyBytes,
3138
+ platform,
3139
+ this.protoPoisoning,
3140
+ )
2998
3141
  : new RequestContext(
2999
3142
  source,
3000
3143
  params,
@@ -3002,18 +3145,32 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3002
3145
  signal,
3003
3146
  budget,
3004
3147
  platform,
3005
- this.maxBodyBytes,
3148
+ maxBodyBytes,
3149
+ this.protoPoisoning,
3006
3150
  ),
3007
3151
  )
3008
3152
  }
3009
3153
  if (result instanceof Promise) {
3010
3154
  return result.then(
3011
- (value) => fusedRespondNoSet(value, this.responseBodyTag, this.staticResponseHeaders),
3155
+ (value) =>
3156
+ fusedRespondNoSet(
3157
+ value,
3158
+ this.responseBodyTag,
3159
+ this.staticResponseHeaders,
3160
+ this.onResponseHooks.length === 0,
3161
+ ),
3012
3162
  (err) =>
3013
3163
  logError(
3014
3164
  err,
3015
3165
  nativeContext
3016
- ? RequestContext.native(source, params, search, this.maxBodyBytes, platform)
3166
+ ? RequestContext.native(
3167
+ source,
3168
+ params,
3169
+ search,
3170
+ maxBodyBytes,
3171
+ platform,
3172
+ this.protoPoisoning,
3173
+ )
3017
3174
  : new RequestContext(
3018
3175
  source,
3019
3176
  params,
@@ -3021,18 +3178,33 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3021
3178
  signal,
3022
3179
  budget,
3023
3180
  platform,
3024
- this.maxBodyBytes,
3181
+ maxBodyBytes,
3182
+ this.protoPoisoning,
3025
3183
  ),
3026
3184
  ),
3027
3185
  )
3028
3186
  }
3029
- return fusedRespondNoSet(result, this.responseBodyTag, this.staticResponseHeaders)
3187
+ return fusedRespondNoSet(
3188
+ result,
3189
+ this.responseBodyTag,
3190
+ this.staticResponseHeaders,
3191
+ this.onResponseHooks.length === 0,
3192
+ )
3030
3193
  }
3031
3194
  }
3032
3195
  return (source, params, search, signal, budget, platform, nativeContext) => {
3033
3196
  const ctx = nativeContext
3034
- ? RequestContext.native(source, params, search, this.maxBodyBytes, platform)
3035
- : new RequestContext(source, params, search, signal, budget, platform, this.maxBodyBytes)
3197
+ ? RequestContext.native(source, params, search, maxBodyBytes, platform, this.protoPoisoning)
3198
+ : new RequestContext(
3199
+ source,
3200
+ params,
3201
+ search,
3202
+ signal,
3203
+ budget,
3204
+ platform,
3205
+ maxBodyBytes,
3206
+ this.protoPoisoning,
3207
+ )
3036
3208
  if (decorations !== undefined) Object.assign(ctx, decorations)
3037
3209
  let result: unknown
3038
3210
  try {
@@ -3042,11 +3214,24 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3042
3214
  }
3043
3215
  if (result instanceof Promise) {
3044
3216
  return result.then(
3045
- (value) => fusedRespond(value, ctx, this.responseBodyTag, this.staticResponseHeaders),
3217
+ (value) =>
3218
+ fusedRespond(
3219
+ value,
3220
+ ctx,
3221
+ this.responseBodyTag,
3222
+ this.staticResponseHeaders,
3223
+ this.onResponseHooks.length === 0,
3224
+ ),
3046
3225
  (err) => logError(err, ctx),
3047
3226
  )
3048
3227
  }
3049
- return fusedRespond(result, ctx, this.responseBodyTag, this.staticResponseHeaders)
3228
+ return fusedRespond(
3229
+ result,
3230
+ ctx,
3231
+ this.responseBodyTag,
3232
+ this.staticResponseHeaders,
3233
+ this.onResponseHooks.length === 0,
3234
+ )
3050
3235
  }
3051
3236
  }
3052
3237
 
@@ -3057,6 +3242,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3057
3242
  handler: InternalHandler,
3058
3243
  bodySchema: StandardSchemaV1,
3059
3244
  decorations: Record<PropertyKey, unknown> | undefined,
3245
+ maxBodyBytes: number,
3060
3246
  ): FusedBodyRunner {
3061
3247
  const logError = <T>(
3062
3248
  err: unknown,
@@ -3149,11 +3335,20 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3149
3335
  wrapResponse: (response: Response) => T,
3150
3336
  ): MaybePromise<T> => {
3151
3337
  const ctx = nativeContext
3152
- ? RequestContext.native(source, params, search, this.maxBodyBytes, platform)
3153
- : new RequestContext(source, params, search, signal, budget, platform, this.maxBodyBytes)
3338
+ ? RequestContext.native(source, params, search, maxBodyBytes, platform, this.protoPoisoning)
3339
+ : new RequestContext(
3340
+ source,
3341
+ params,
3342
+ search,
3343
+ signal,
3344
+ budget,
3345
+ platform,
3346
+ maxBodyBytes,
3347
+ this.protoPoisoning,
3348
+ )
3154
3349
  const finish = (value: unknown): MaybePromise<T> =>
3155
3350
  runParsed(value, ctx, finalize, wrapResponse)
3156
- return this.readBodyInput(source, finish, wrapResponse, (err) =>
3351
+ return this.readBodyInput(source, maxBodyBytes, finish, wrapResponse, (err) =>
3157
3352
  logError(err, ctx, wrapResponse),
3158
3353
  )
3159
3354
  }
@@ -3172,7 +3367,13 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3172
3367
  platform,
3173
3368
  nativeContext,
3174
3369
  (result, _set, ctx) =>
3175
- fusedRespond(result, ctx, this.responseBodyTag, this.staticResponseHeaders),
3370
+ fusedRespond(
3371
+ result,
3372
+ ctx,
3373
+ this.responseBodyTag,
3374
+ this.staticResponseHeaders,
3375
+ this.onResponseHooks.length === 0,
3376
+ ),
3176
3377
  this.wrapWebResponse,
3177
3378
  ) as MaybePromise<Response>
3178
3379
  }
@@ -3188,6 +3389,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3188
3389
  handler: InternalHandler,
3189
3390
  decorations: Record<PropertyKey, unknown> | undefined,
3190
3391
  querySchema: StandardSchemaV1,
3392
+ maxBodyBytes: number,
3191
3393
  ): FusedWebRunner {
3192
3394
  // The fused lanes return to the runtime directly, so the framework's own error renders fold in
3193
3395
  // static headers here rather than through the shared `wrapResponse` seam.
@@ -3206,16 +3408,38 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3206
3408
  }
3207
3409
  if (result instanceof Promise) {
3208
3410
  return result.then(
3209
- (settled) => fusedRespond(settled, ctx, this.responseBodyTag, this.staticResponseHeaders),
3411
+ (settled) =>
3412
+ fusedRespond(
3413
+ settled,
3414
+ ctx,
3415
+ this.responseBodyTag,
3416
+ this.staticResponseHeaders,
3417
+ this.onResponseHooks.length === 0,
3418
+ ),
3210
3419
  (err) => logError(err, ctx),
3211
3420
  )
3212
3421
  }
3213
- return fusedRespond(result, ctx, this.responseBodyTag, this.staticResponseHeaders)
3422
+ return fusedRespond(
3423
+ result,
3424
+ ctx,
3425
+ this.responseBodyTag,
3426
+ this.staticResponseHeaders,
3427
+ this.onResponseHooks.length === 0,
3428
+ )
3214
3429
  }
3215
3430
  return (source, params, search, signal, budget, platform, nativeContext) => {
3216
3431
  const ctx = nativeContext
3217
- ? RequestContext.native(source, params, search, this.maxBodyBytes, platform)
3218
- : new RequestContext(source, params, search, signal, budget, platform, this.maxBodyBytes)
3432
+ ? RequestContext.native(source, params, search, maxBodyBytes, platform, this.protoPoisoning)
3433
+ : new RequestContext(
3434
+ source,
3435
+ params,
3436
+ search,
3437
+ signal,
3438
+ budget,
3439
+ platform,
3440
+ maxBodyBytes,
3441
+ this.protoPoisoning,
3442
+ )
3219
3443
  if (decorations !== undefined) Object.assign(ctx, decorations)
3220
3444
  let validation: MaybePromise<StandardResult<unknown>>
3221
3445
  try {
@@ -3244,6 +3468,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3244
3468
  return wrapResponse(jsonError(500, "internal_error"))
3245
3469
  }
3246
3470
 
3471
+ // @ts-expect-error TS6133 -- invoked structurally by compiled plans (internal/route-execution.ts)
3472
+ // biome-ignore lint/correctness/noUnusedPrivateClassMembers: invoked structurally by compiled plans (internal/route-execution.ts)
3247
3473
  private runWithAround<T>(
3248
3474
  entry: RouteEntry,
3249
3475
  ctx: RawContext,
@@ -3280,6 +3506,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3280
3506
  return dispatch(0)
3281
3507
  }
3282
3508
 
3509
+ // @ts-expect-error TS6133 -- invoked structurally by compiled plans (internal/route-execution.ts)
3510
+ // biome-ignore lint/correctness/noUnusedPrivateClassMembers: invoked structurally by compiled plans (internal/route-execution.ts)
3283
3511
  private runBodyOnly<T>(
3284
3512
  entry: RouteEntry,
3285
3513
  source: RequestSource,
@@ -3289,6 +3517,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3289
3517
  ): Promise<T> {
3290
3518
  return this.readBodyInput(
3291
3519
  source,
3520
+ entry.bodyLimit ?? this.maxBodyBytes,
3292
3521
  (parsed) => this.finishBodyOnly(entry, parsed, ctx, finalize, wrapResponse),
3293
3522
  wrapResponse,
3294
3523
  (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse),
@@ -3299,6 +3528,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3299
3528
  * the validation + handler continuation differs. */
3300
3529
  private readBodyInput<T>(
3301
3530
  source: RequestSource,
3531
+ maxBodyBytes: number,
3302
3532
  onParsed: (parsed: unknown) => MaybePromise<T>,
3303
3533
  wrapResponse: (response: Response) => T,
3304
3534
  onError: (err: unknown) => MaybePromise<T>,
@@ -3306,7 +3536,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3306
3536
  const contentType = headerOf(source, "content-type") ?? ""
3307
3537
  if (contentType !== "application/json" && !contentType.includes("application/json")) {
3308
3538
  if (isUrlEncodedForm(contentType)) {
3309
- return readBoundedForm(source, this.maxBodyBytes).then(
3539
+ return readBoundedForm(source, maxBodyBytes).then(
3310
3540
  (form) => (form instanceof Response ? wrapResponse(form) : onParsed(form)),
3311
3541
  onError,
3312
3542
  ) as Promise<T>
@@ -3314,24 +3544,13 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3314
3544
  return Promise.resolve(wrapResponse(jsonError(415, "unsupported_media_type")))
3315
3545
  }
3316
3546
 
3317
- // A framed, in-cap, non-chunked body can use native json() directly. Chunked or length-less
3318
- // bodies use readBoundedJson, which enforces the streaming cap before parsing.
3319
- const declared = headerOf(source, "content-length")
3320
- if (declared !== null) {
3321
- const length = parseContentLength(declared)
3322
- if (length === undefined) {
3323
- return Promise.resolve(wrapResponse(jsonError(400, "invalid_content_length")))
3324
- }
3325
- if (length > this.maxBodyBytes) {
3326
- return Promise.resolve(wrapResponse(jsonError(413, "payload_too_large")))
3327
- }
3328
- if (headerOf(source, "transfer-encoding") === null) {
3329
- return source.json().then(onParsed, () => wrapResponse(jsonError(400, "invalid_json")))
3330
- }
3331
- }
3332
-
3547
+ // All JSON bodies go through readBoundedJson - the single enforcement point for the framed
3548
+ // fast path (post-read byte re-check), the streaming cap, and the prototype-poisoning guard.
3549
+ // A separate inlined native-json() fast path here would fork the trust boundary in two.
3333
3550
  try {
3334
- return this.readBoundedJson(source).then(
3551
+ return this.readBoundedJson(
3552
+ source,
3553
+ maxBodyBytes,
3335
3554
  (parsed) => (parsed instanceof Response ? wrapResponse(parsed) : onParsed(parsed)),
3336
3555
  onError,
3337
3556
  )
@@ -3487,6 +3706,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3487
3706
  return this.executeHandler(entry, ctx, finalize)
3488
3707
  }
3489
3708
 
3709
+ // @ts-expect-error TS6133 -- invoked structurally by compiled plans (internal/route-execution.ts)
3710
+ // biome-ignore lint/correctness/noUnusedPrivateClassMembers: invoked structurally by compiled plans (internal/route-execution.ts)
3490
3711
  private runQueryOnly<T>(
3491
3712
  entry: RouteEntry,
3492
3713
  ctx: RawContext,
@@ -3693,6 +3914,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3693
3914
  * node-direct primitives); an early/error `Response` (validation, thrown, 500) through `wrapResponse`.
3694
3915
  */
3695
3916
  /** Synchronous until a validator, lifecycle hook, handler, or contract check actually returns a Promise. */
3917
+ // @ts-expect-error TS6133 -- invoked structurally by compiled plans (internal/route-execution.ts)
3918
+ // biome-ignore lint/correctness/noUnusedPrivateClassMembers: invoked structurally by compiled plans (internal/route-execution.ts)
3696
3919
  private runLifecycle<T>(
3697
3920
  entry: RouteEntry,
3698
3921
  source: RequestSource,
@@ -3701,6 +3924,32 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3701
3924
  wrapResponse: (response: Response) => T,
3702
3925
  ): MaybePromise<T> {
3703
3926
  try {
3927
+ const headersSchema = entry.schema?.headers
3928
+ if (headersSchema !== undefined) {
3929
+ const validation = headersSchema["~standard"].validate(headerObjectOf(source.headers))
3930
+ if (validation instanceof Promise) {
3931
+ return validation.then(
3932
+ (result) =>
3933
+ this.runLifecycleAfterHeadersResult(
3934
+ entry,
3935
+ source,
3936
+ ctx,
3937
+ finalize,
3938
+ wrapResponse,
3939
+ result,
3940
+ ),
3941
+ (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse),
3942
+ )
3943
+ }
3944
+ return this.runLifecycleAfterHeadersResult(
3945
+ entry,
3946
+ source,
3947
+ ctx,
3948
+ finalize,
3949
+ wrapResponse,
3950
+ validation,
3951
+ )
3952
+ }
3704
3953
  const paramsSchema = entry.schema?.params
3705
3954
  if (paramsSchema !== undefined) {
3706
3955
  const validation = paramsSchema["~standard"].validate(ctx.params)
@@ -3740,6 +3989,64 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3740
3989
  }
3741
3990
  }
3742
3991
 
3992
+ private runLifecycleAfterHeadersResult<T>(
3993
+ entry: RouteEntry,
3994
+ source: RequestSource,
3995
+ ctx: RawContext,
3996
+ finalize: (result: unknown, set: CtxSet) => T,
3997
+ wrapResponse: (response: Response) => T,
3998
+ result: StandardResult<unknown>,
3999
+ ): MaybePromise<T> {
4000
+ try {
4001
+ const headersError = this.applyLifecycleValidation(entry, result, ctx, "headers")
4002
+ if (headersError instanceof Promise) {
4003
+ return headersError.then(
4004
+ (error) =>
4005
+ error === undefined
4006
+ ? this.runLifecycleAfterHeaders(entry, source, ctx, finalize, wrapResponse)
4007
+ : wrapResponse(error),
4008
+ (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse),
4009
+ )
4010
+ }
4011
+ return headersError === undefined
4012
+ ? this.runLifecycleAfterHeaders(entry, source, ctx, finalize, wrapResponse)
4013
+ : wrapResponse(headersError)
4014
+ } catch (err) {
4015
+ return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse)
4016
+ }
4017
+ }
4018
+
4019
+ private runLifecycleAfterHeaders<T>(
4020
+ entry: RouteEntry,
4021
+ source: RequestSource,
4022
+ ctx: RawContext,
4023
+ finalize: (result: unknown, set: CtxSet) => T,
4024
+ wrapResponse: (response: Response) => T,
4025
+ ): MaybePromise<T> {
4026
+ const paramsSchema = entry.schema?.params
4027
+ if (paramsSchema === undefined) {
4028
+ if (entry.schema?.body === undefined)
4029
+ return this.runQueryAndLifecycle(entry, ctx, finalize, wrapResponse)
4030
+ return this.runLifecycleAfterParams(entry, source, ctx, finalize, wrapResponse, undefined)
4031
+ }
4032
+ const validation = paramsSchema["~standard"].validate(ctx.params)
4033
+ if (validation instanceof Promise) {
4034
+ return validation.then(
4035
+ (result) =>
4036
+ this.runLifecycleAfterParamsResult(entry, source, ctx, finalize, wrapResponse, result),
4037
+ (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse),
4038
+ )
4039
+ }
4040
+ return this.runLifecycleAfterParamsResult(
4041
+ entry,
4042
+ source,
4043
+ ctx,
4044
+ finalize,
4045
+ wrapResponse,
4046
+ validation,
4047
+ )
4048
+ }
4049
+
3743
4050
  /** Registration-specialized query lifecycle: query validation (including recovery) → hooks. */
3744
4051
  private runLifecycleQuery<T>(
3745
4052
  entry: RouteEntry,
@@ -3772,6 +4079,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3772
4079
  }
3773
4080
 
3774
4081
  /** Registration-specialized body lifecycle: bounded body validation (including recovery) → hooks. */
4082
+ // @ts-expect-error TS6133 -- invoked structurally by compiled plans (internal/route-execution.ts)
4083
+ // biome-ignore lint/correctness/noUnusedPrivateClassMembers: invoked structurally by compiled plans (internal/route-execution.ts)
3775
4084
  private runLifecycleBody<T>(
3776
4085
  entry: RouteEntry,
3777
4086
  source: RequestSource,
@@ -3789,6 +4098,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3789
4098
  }
3790
4099
 
3791
4100
  /** Registration-specialized body + query lifecycle: bounded body validation → query validation → hooks. */
4101
+ // @ts-expect-error TS6133 -- invoked structurally by compiled plans (internal/route-execution.ts)
4102
+ // biome-ignore lint/correctness/noUnusedPrivateClassMembers: invoked structurally by compiled plans (internal/route-execution.ts)
3792
4103
  private runLifecycleBodyQuery<T>(
3793
4104
  entry: RouteEntry,
3794
4105
  source: RequestSource,
@@ -4070,8 +4381,12 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
4070
4381
  wrapResponse: (response: Response) => T,
4071
4382
  ): Promise<T> {
4072
4383
  try {
4073
- for (const hook of entry.beforeHandle) {
4074
- const outcome = hook(ctx)
4384
+ // Indexed, not `for...of`. An array iterator declared inside an async function stays live
4385
+ // across the `await` below, so neither engine can sink it - unlike the sync lanes above,
4386
+ // where V8 elides it. Measured cost of the iterator form: 43ns per hook on Bun, ~9ns on
4387
+ // Node, on a chain whose hooks return synchronously (the common shape).
4388
+ for (let i = 0; i < entry.beforeHandle.length; i++) {
4389
+ const outcome = entry.beforeHandle[i]!(ctx)
4075
4390
  const early = outcome instanceof Promise ? await outcome : outcome
4076
4391
  if (early !== undefined) return finalize(early, responseSet(ctx))
4077
4392
  }
@@ -4231,8 +4546,10 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
4231
4546
  // any handler or loader.
4232
4547
  if (err instanceof Response) return wrapResponse(err)
4233
4548
  // onError hooks may return a custom response; otherwise the default 500 stands.
4234
- for (const hook of entry.onError) {
4235
- const outcome = hook(err, ctx)
4549
+ // Indexed for the same reason as the `beforeHandle` chain above: the iterator would outlive
4550
+ // the `await` and stay heap-allocated on both engines.
4551
+ for (let i = 0; i < entry.onError.length; i++) {
4552
+ const outcome = entry.onError[i]!(err, ctx)
4236
4553
  const handled = outcome instanceof Promise ? await outcome : outcome
4237
4554
  if (handled !== undefined) return finalize(handled, responseSet(ctx))
4238
4555
  }
@@ -4245,6 +4562,11 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
4245
4562
  /** Log an unhandled request error to the (redacting) logger - shared by {@link runLifecycle} and the
4246
4563
  * bare fast path ({@link bareError}) so both record the same fields. Never throws; never leaks. */
4247
4564
  private logRequestError(err: unknown, ctx: RawContext): void {
4565
+ // An error's own text can quote the input that produced it, so how much of it reaches the sink is
4566
+ // the app's call (`errorLogDetail`). The default keeps it: a 500 with no message and no stack is
4567
+ // an incident nobody can diagnose, and the framework already ships the narrower instrument for
4568
+ // the leak - a redacting logger with `valuePatterns`. `"none"` is there for an untrusted sink.
4569
+ const detail = this.errorLogDetail
4248
4570
  this.logger.error("unhandled request error", {
4249
4571
  method: ctx.req.method,
4250
4572
  path: pathnameOf(ctx.req.url),
@@ -4252,8 +4574,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
4252
4574
  // `detail`, not `message`: the logger uses `message` for its own first argument, so a field of
4253
4575
  // that name is silently overwritten and the thrown error's own text never reaches the sink. It
4254
4576
  // survived only incidentally inside `stack`, and was lost outright for a non-Error throw.
4255
- detail: err instanceof Error ? err.message : String(err),
4256
- stack: err instanceof Error ? err.stack : undefined,
4577
+ ...(detail === "none" ? {} : { detail: err instanceof Error ? err.message : String(err) }),
4578
+ ...(detail === "full" && err instanceof Error ? { stack: err.stack } : {}),
4257
4579
  })
4258
4580
  }
4259
4581
 
@@ -4265,11 +4587,11 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
4265
4587
  const contentType = headerOf(req, "content-type") ?? ""
4266
4588
  let parsed: unknown
4267
4589
  if (contentType === "application/json" || contentType.includes("application/json")) {
4268
- const json = await this.readBoundedJson(req)
4590
+ const json = await this.readBoundedJson(req, entry.bodyLimit ?? this.maxBodyBytes)
4269
4591
  if (json instanceof Response) return json
4270
4592
  parsed = json
4271
4593
  } else if (isUrlEncodedForm(contentType)) {
4272
- const form = await readBoundedForm(req, this.maxBodyBytes)
4594
+ const form = await readBoundedForm(req, entry.bodyLimit ?? this.maxBodyBytes)
4273
4595
  if (form instanceof Response) return form
4274
4596
  parsed = form
4275
4597
  } else {
@@ -4288,11 +4610,12 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
4288
4610
  entry: RouteEntry,
4289
4611
  result: StandardResult<unknown>,
4290
4612
  ctx: RawContext,
4291
- kind: "body" | "query" | "params",
4613
+ kind: "body" | "query" | "params" | "headers",
4292
4614
  ): MaybePromise<Response | undefined> {
4293
4615
  const assign = (value: unknown): void => {
4294
4616
  if (kind === "body") ctx.body = value
4295
4617
  else if (kind === "query") ctx.query = value
4618
+ else if (kind === "headers") ctx.headers = value as Record<string, string>
4296
4619
  else ctx.params = value as Record<string, string>
4297
4620
  }
4298
4621
  if (result.issues === undefined) {
@@ -4312,7 +4635,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
4312
4635
 
4313
4636
  private finishLifecycleValidationRecovery(
4314
4637
  entry: RouteEntry,
4315
- kind: "body" | "query" | "params",
4638
+ kind: "body" | "query" | "params" | "headers",
4316
4639
  issues: ReadonlyArray<StandardIssue>,
4317
4640
  recovery: unknown,
4318
4641
  assign: (value: unknown) => void,
@@ -4324,7 +4647,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
4324
4647
  ? entry.schema?.body
4325
4648
  : kind === "query"
4326
4649
  ? entry.schema?.query
4327
- : entry.schema?.params
4650
+ : kind === "params"
4651
+ ? entry.schema?.params
4652
+ : entry.schema?.headers
4328
4653
  const retried = schema!["~standard"].validate(recovery)
4329
4654
  if (retried instanceof Promise) {
4330
4655
  return retried.then((settled) => {
@@ -4339,21 +4664,30 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
4339
4664
  }
4340
4665
 
4341
4666
  /**
4342
- * Read the body as text, capped at `maxBodyBytes`. Rejects (`null`) on a
4343
- * `Content-Length` over the cap *before* buffering, and aborts mid-stream once the
4344
- * running byte count exceeds it - so a lying or absent length can't force us to
4345
- * buffer an oversized payload.
4346
- *
4347
- * Fast path: when a non-chunked request carries a `Content-Length` within the cap,
4348
- * a native `req.json()` is already bounded - under HTTP/1.1 + HTTP/2 framing the
4349
- * runtime delivers at most `Content-Length` bytes - so we skip the manual stream
4350
- * loop and a separate text decode. It trusts the wire
4351
- * *framing*, not the header value: nifra only ever receives framed Requests from the
4352
- * runtime's HTTP server, never a hand-built one with a mismatched length. Chunked or
4353
- * length-less bodies fall through to the streaming byte-cap guard below.
4667
+ * Read + parse the JSON body, capped at `maxBodyBytes` and screened by the server's
4668
+ * `protoPoisoning` policy. Rejects a `Content-Length` over the cap *before* buffering,
4669
+ * re-checks what was actually delivered after the read, and aborts a chunked or
4670
+ * length-less body mid-stream once the running byte count exceeds the cap - so a lying
4671
+ * or absent length can't force us to buffer an oversized payload.
4354
4672
  */
4355
- private async readBoundedJson(req: RequestSource): Promise<unknown | Response> {
4356
- return readBoundedJsonSource(req, this.maxBodyBytes)
4673
+ private readBoundedJson(req: RequestSource, maxBodyBytes?: number): Promise<unknown | Response>
4674
+ private readBoundedJson<T>(
4675
+ req: RequestSource,
4676
+ maxBodyBytes: number,
4677
+ onResult: (parsed: unknown | Response) => T | Promise<T>,
4678
+ onError: (error: unknown) => T | Promise<T>,
4679
+ ): Promise<T>
4680
+ private readBoundedJson<T>(
4681
+ req: RequestSource,
4682
+ maxBodyBytes = this.maxBodyBytes,
4683
+ onResult?: (parsed: unknown | Response) => T | Promise<T>,
4684
+ onError?: (error: unknown) => T | Promise<T>,
4685
+ ): Promise<unknown | Response | T> {
4686
+ // Not `async`: this is a pass-through, and wrapping the lane's own promise in a coroutine
4687
+ // frame costs an extra microtask hop on every JSON body.
4688
+ return onResult === undefined
4689
+ ? readBoundedJsonSource(req, maxBodyBytes, this.protoPoisoning)
4690
+ : readBoundedJsonSource(req, maxBodyBytes, this.protoPoisoning, onResult, onError!)
4357
4691
  }
4358
4692
 
4359
4693
  /** Adapt one route's compiled execution plan to Bun's already-matched request shape. Route
@@ -4365,25 +4699,51 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
4365
4699
  signal: AbortSignal | undefined,
4366
4700
  budget: RequestBudget | undefined,
4367
4701
  ): BunNativeHandler {
4702
+ // This callback is reached only from Bun's compiled native route table. Bun has already parsed
4703
+ // the HTTP framing, so the JSON lane may retain its native fused `json()` parse without weakening
4704
+ // the portable/adapted source contract (those sources are never marked).
4705
+ const markFramed = (request: Request): void => markTrustedBodyFraming(request)
4706
+ // The fused native lane bypasses `runMatched`, so the route's transport byte cap must be
4707
+ // marked here too - otherwise a Bun `listen()` fused route would leave direct `c.req` body
4708
+ // reads uncapped. The non-fused branches go through `fetchMatched` -> `runMatched`, which marks.
4709
+ const bodyLimit = entry.schema?.body === undefined ? entry.bodyLimit : undefined
4710
+ const inner = fused
4711
+ const capped: FusedWebRunner | undefined =
4712
+ inner === undefined || bodyLimit === undefined
4713
+ ? inner
4714
+ : (source, params, search, fusedSignal, fusedBudget, platform, nativeContext) => {
4715
+ const method = source.method
4716
+ if (method !== "GET" && method !== "HEAD") markTransportCap(source, bodyLimit)
4717
+ return inner(source, params, search, fusedSignal, fusedBudget, platform, nativeContext)
4718
+ }
4368
4719
  if (paramNames.length === 0) {
4369
- if (fused === undefined) {
4370
- return (request) => this.fetchMatched(request, entry, EMPTY_PARAMS)
4720
+ if (capped === undefined) {
4721
+ return (request) => {
4722
+ markFramed(request)
4723
+ return this.fetchMatched(request, entry, EMPTY_PARAMS)
4724
+ }
4371
4725
  }
4372
4726
  if (this.acceptInboundDeadlines) {
4373
- return (request) =>
4374
- request.headers.get(NIFRA_DEADLINE_HEADER) !== null
4727
+ return (request) => {
4728
+ markFramed(request)
4729
+ return request.headers.get(NIFRA_DEADLINE_HEADER) !== null
4375
4730
  ? this.fetchMatched(request, entry, EMPTY_PARAMS)
4376
- : fused(request, EMPTY_PARAMS, undefined, signal!, budget!, undefined, true)
4731
+ : capped(request, EMPTY_PARAMS, undefined, signal!, budget!, undefined, true)
4732
+ }
4733
+ }
4734
+ return (request) => {
4735
+ markFramed(request)
4736
+ return capped(request, EMPTY_PARAMS, undefined, signal!, budget!, undefined, true)
4377
4737
  }
4378
- return (request) => fused(request, EMPTY_PARAMS, undefined, signal!, budget!, undefined, true)
4379
4738
  }
4380
4739
 
4381
4740
  const malformed =
4382
4741
  paramNames.length === 1
4383
4742
  ? (params: Record<string, string>) => params[paramNames[0]!]?.includes("\uFFFD") === true
4384
4743
  : hasReplacementParam
4385
- if (fused === undefined) {
4744
+ if (capped === undefined) {
4386
4745
  return (request) => {
4746
+ markFramed(request)
4387
4747
  const params = (request as BunRequestWithParams).params ?? EMPTY_PARAMS
4388
4748
  if (malformed(params)) return this.fetchSource(request)
4389
4749
  return this.fetchMatched(request, entry, params)
@@ -4391,17 +4751,19 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
4391
4751
  }
4392
4752
  if (this.acceptInboundDeadlines) {
4393
4753
  return (request) => {
4754
+ markFramed(request)
4394
4755
  const params = (request as BunRequestWithParams).params ?? EMPTY_PARAMS
4395
4756
  if (malformed(params)) return this.fetchSource(request)
4396
4757
  return request.headers.get(NIFRA_DEADLINE_HEADER) !== null
4397
4758
  ? this.fetchMatched(request, entry, params)
4398
- : fused(request, params, undefined, signal!, budget!, undefined, true)
4759
+ : capped(request, params, undefined, signal!, budget!, undefined, true)
4399
4760
  }
4400
4761
  }
4401
4762
  return (request) => {
4763
+ markFramed(request)
4402
4764
  const params = (request as BunRequestWithParams).params ?? EMPTY_PARAMS
4403
4765
  if (malformed(params)) return this.fetchSource(request)
4404
- return fused(request, params, undefined, signal!, budget!, undefined, true)
4766
+ return capped(request, params, undefined, signal!, budget!, undefined, true)
4405
4767
  }
4406
4768
  }
4407
4769
 
@@ -4476,10 +4838,21 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
4476
4838
  * `"127.0.0.1"` to bind loopback only - an admin surface, a sidecar, or anything that must not be
4477
4839
  * reachable off the box. Omitting it when you meant to restrict is a real exposure, so it is a
4478
4840
  * first-class option rather than something a caller has to drop down to `Bun.serve` for.
4841
+ *
4842
+ * `idleTimeoutSec` is how long Bun tolerates a connection with no bytes moving before it closes
4843
+ * the socket. Bun's default is **10 seconds**, and a request whose handler is still working sends
4844
+ * nothing - so any endpoint slower than 10s (a render, an export, a long upstream call) has its
4845
+ * connection cut mid-flight regardless of `requestTimeoutMs`. Apps with such endpoints must raise
4846
+ * this above their slowest expected response, which is why it is a first-class option and not a
4847
+ * reason to drop down to `Bun.serve`. `0` disables the timeout entirely; max 255.
4479
4848
  */
4480
4849
  listen(
4481
4850
  port: number,
4482
- options?: { readonly reusePort?: boolean; readonly hostname?: string },
4851
+ options?: {
4852
+ readonly reusePort?: boolean
4853
+ readonly hostname?: string
4854
+ readonly idleTimeoutSec?: number
4855
+ },
4483
4856
  ): RunningServer {
4484
4857
  if (typeof Bun === "undefined") {
4485
4858
  // listen() is the one Bun-specific seam. Off Bun, fail loud + actionable rather
@@ -4509,12 +4882,16 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
4509
4882
  // Spread rather than pass `hostname: undefined` - Bun treats an explicit undefined as a value
4510
4883
  // on some option paths, and omitting is what selects its 0.0.0.0 default.
4511
4884
  const bind = options?.hostname === undefined ? {} : { hostname: options.hostname }
4885
+ // Same reasoning as `bind`: omit rather than pass undefined, so Bun's own default applies.
4886
+ const idle =
4887
+ options?.idleTimeoutSec === undefined ? {} : { idleTimeout: options.idleTimeoutSec }
4512
4888
  const nativeRoutes = wsHandlers === undefined ? this.buildBunNativeRoutes() : undefined
4513
4889
  const running = (wsHandlers === undefined
4514
4890
  ? Bun.serve({
4515
4891
  port,
4516
4892
  reusePort,
4517
4893
  ...bind,
4894
+ ...idle,
4518
4895
  ...(nativeRoutes === undefined ? {} : { routes: nativeRoutes }),
4519
4896
  fetch: (req: Request, server) =>
4520
4897
  this.fetch(req, bunPeerPlatform(server, req) as Platform<EnvOf<Ctx>>),
@@ -4523,6 +4900,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
4523
4900
  port,
4524
4901
  reusePort,
4525
4902
  ...bind,
4903
+ ...idle,
4526
4904
  fetch: (req, server) => this.bunFetchWithWebSocket(req, server),
4527
4905
  // Bun's `ServerWebSocket<BunWsData>` is runtime-compatible with the handlers' structural
4528
4906
  // `BunSocket` view (kept local so `Bun.*` types never leak into the published .d.ts); the
@@ -4562,6 +4940,13 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
4562
4940
  await Bun.sleep(DRAIN_POLL_MS)
4563
4941
  }
4564
4942
  server.stop(server.pendingRequests > 0) // force-close iff stragglers remain past the deadline
4943
+ if (this.stopHooks.length === 0) return
4944
+ const callbacks = this.stopHooks.map((hook) => Promise.resolve().then(hook))
4945
+ const settled = Promise.allSettled(callbacks)
4946
+ await Promise.race([
4947
+ settled,
4948
+ new Promise<void>((resolve) => setTimeout(resolve, STOP_HOOK_TIMEOUT_MS)),
4949
+ ])
4565
4950
  }
4566
4951
 
4567
4952
  private installSignalHandlers(): void {