@nifrajs/core 2.11.0 → 2.12.0

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 +424 -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 +667 -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,15 @@ 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
+ if (entry.bodyLimit !== undefined) {
2845
+ const method = source.method
2846
+ if (method !== "GET" && method !== "HEAD") markTransportCap(source, entry.bodyLimit)
2847
+ }
2723
2848
  // An idempotency route runs its dedupe lane first; on a fresh key it delegates to the normal lanes
2724
2849
  // (with the body buffered). All non-idempotent routes skip straight to the lanes - no added cost.
2725
2850
  // The runtime is always present when a route resolved idempotency (enforced at registration).
@@ -2733,7 +2858,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2733
2858
  search,
2734
2859
  wrapResponse,
2735
2860
  {
2736
- maxBodyBytes: this.maxBodyBytes,
2861
+ maxBodyBytes: entry.bodyLimit ?? this.maxBodyBytes,
2737
2862
  runLanes: (buffered, plat, ent, prm, srch) =>
2738
2863
  this.idempotencyRunLanes(buffered, plat, ent as RouteEntry, prm, srch),
2739
2864
  },
@@ -2861,6 +2986,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2861
2986
 
2862
2987
  /** The narrowest bare route: a syntactic `() => ...` handler cannot observe the context argument, so
2863
2988
  * successful requests can skip allocating `RequestContext`. Errors still allocate one for logging. */
2989
+ // @ts-expect-error TS6133 -- invoked structurally by compiled plans (internal/route-execution.ts)
2990
+ // biome-ignore lint/correctness/noUnusedPrivateClassMembers: invoked structurally by compiled plans (internal/route-execution.ts)
2864
2991
  private runContextlessBare<T>(
2865
2992
  entry: RouteEntry,
2866
2993
  source: RequestSource,
@@ -2925,6 +3052,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2925
3052
  budget,
2926
3053
  platform,
2927
3054
  this.maxBodyBytes,
3055
+ this.protoPoisoning,
2928
3056
  )
2929
3057
  this.logRequestError(err, ctx)
2930
3058
  return wrapResponse(jsonError(500, "internal_error"))
@@ -2938,6 +3066,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2938
3066
  * (a thrown `Response` is control flow; anything else is a logged flat 500). This is where nifra skips
2939
3067
  * the per-request async-frame tax - the same win codegen routers get, but without `eval`.
2940
3068
  */
3069
+ // @ts-expect-error TS6133 -- invoked structurally by compiled plans (internal/route-execution.ts)
3070
+ // biome-ignore lint/correctness/noUnusedPrivateClassMembers: invoked structurally by compiled plans (internal/route-execution.ts)
2941
3071
  private runBare<T>(
2942
3072
  entry: RouteEntry,
2943
3073
  ctx: RawContext,
@@ -2974,6 +3104,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2974
3104
  handler: InternalHandler,
2975
3105
  decorations: Record<PropertyKey, unknown> | undefined,
2976
3106
  contextless: boolean,
3107
+ maxBodyBytes: number,
2977
3108
  ): FusedWebRunner {
2978
3109
  // The fused lanes return to the runtime directly, so the framework's own error renders fold in
2979
3110
  // static headers here rather than through the shared `wrapResponse` seam.
@@ -2994,7 +3125,14 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2994
3125
  return logError(
2995
3126
  err,
2996
3127
  nativeContext
2997
- ? RequestContext.native(source, params, search, this.maxBodyBytes, platform)
3128
+ ? RequestContext.native(
3129
+ source,
3130
+ params,
3131
+ search,
3132
+ maxBodyBytes,
3133
+ platform,
3134
+ this.protoPoisoning,
3135
+ )
2998
3136
  : new RequestContext(
2999
3137
  source,
3000
3138
  params,
@@ -3002,18 +3140,32 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3002
3140
  signal,
3003
3141
  budget,
3004
3142
  platform,
3005
- this.maxBodyBytes,
3143
+ maxBodyBytes,
3144
+ this.protoPoisoning,
3006
3145
  ),
3007
3146
  )
3008
3147
  }
3009
3148
  if (result instanceof Promise) {
3010
3149
  return result.then(
3011
- (value) => fusedRespondNoSet(value, this.responseBodyTag, this.staticResponseHeaders),
3150
+ (value) =>
3151
+ fusedRespondNoSet(
3152
+ value,
3153
+ this.responseBodyTag,
3154
+ this.staticResponseHeaders,
3155
+ this.onResponseHooks.length === 0,
3156
+ ),
3012
3157
  (err) =>
3013
3158
  logError(
3014
3159
  err,
3015
3160
  nativeContext
3016
- ? RequestContext.native(source, params, search, this.maxBodyBytes, platform)
3161
+ ? RequestContext.native(
3162
+ source,
3163
+ params,
3164
+ search,
3165
+ maxBodyBytes,
3166
+ platform,
3167
+ this.protoPoisoning,
3168
+ )
3017
3169
  : new RequestContext(
3018
3170
  source,
3019
3171
  params,
@@ -3021,18 +3173,33 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3021
3173
  signal,
3022
3174
  budget,
3023
3175
  platform,
3024
- this.maxBodyBytes,
3176
+ maxBodyBytes,
3177
+ this.protoPoisoning,
3025
3178
  ),
3026
3179
  ),
3027
3180
  )
3028
3181
  }
3029
- return fusedRespondNoSet(result, this.responseBodyTag, this.staticResponseHeaders)
3182
+ return fusedRespondNoSet(
3183
+ result,
3184
+ this.responseBodyTag,
3185
+ this.staticResponseHeaders,
3186
+ this.onResponseHooks.length === 0,
3187
+ )
3030
3188
  }
3031
3189
  }
3032
3190
  return (source, params, search, signal, budget, platform, nativeContext) => {
3033
3191
  const ctx = nativeContext
3034
- ? RequestContext.native(source, params, search, this.maxBodyBytes, platform)
3035
- : new RequestContext(source, params, search, signal, budget, platform, this.maxBodyBytes)
3192
+ ? RequestContext.native(source, params, search, maxBodyBytes, platform, this.protoPoisoning)
3193
+ : new RequestContext(
3194
+ source,
3195
+ params,
3196
+ search,
3197
+ signal,
3198
+ budget,
3199
+ platform,
3200
+ maxBodyBytes,
3201
+ this.protoPoisoning,
3202
+ )
3036
3203
  if (decorations !== undefined) Object.assign(ctx, decorations)
3037
3204
  let result: unknown
3038
3205
  try {
@@ -3042,11 +3209,24 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3042
3209
  }
3043
3210
  if (result instanceof Promise) {
3044
3211
  return result.then(
3045
- (value) => fusedRespond(value, ctx, this.responseBodyTag, this.staticResponseHeaders),
3212
+ (value) =>
3213
+ fusedRespond(
3214
+ value,
3215
+ ctx,
3216
+ this.responseBodyTag,
3217
+ this.staticResponseHeaders,
3218
+ this.onResponseHooks.length === 0,
3219
+ ),
3046
3220
  (err) => logError(err, ctx),
3047
3221
  )
3048
3222
  }
3049
- return fusedRespond(result, ctx, this.responseBodyTag, this.staticResponseHeaders)
3223
+ return fusedRespond(
3224
+ result,
3225
+ ctx,
3226
+ this.responseBodyTag,
3227
+ this.staticResponseHeaders,
3228
+ this.onResponseHooks.length === 0,
3229
+ )
3050
3230
  }
3051
3231
  }
3052
3232
 
@@ -3057,6 +3237,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3057
3237
  handler: InternalHandler,
3058
3238
  bodySchema: StandardSchemaV1,
3059
3239
  decorations: Record<PropertyKey, unknown> | undefined,
3240
+ maxBodyBytes: number,
3060
3241
  ): FusedBodyRunner {
3061
3242
  const logError = <T>(
3062
3243
  err: unknown,
@@ -3149,11 +3330,20 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3149
3330
  wrapResponse: (response: Response) => T,
3150
3331
  ): MaybePromise<T> => {
3151
3332
  const ctx = nativeContext
3152
- ? RequestContext.native(source, params, search, this.maxBodyBytes, platform)
3153
- : new RequestContext(source, params, search, signal, budget, platform, this.maxBodyBytes)
3333
+ ? RequestContext.native(source, params, search, maxBodyBytes, platform, this.protoPoisoning)
3334
+ : new RequestContext(
3335
+ source,
3336
+ params,
3337
+ search,
3338
+ signal,
3339
+ budget,
3340
+ platform,
3341
+ maxBodyBytes,
3342
+ this.protoPoisoning,
3343
+ )
3154
3344
  const finish = (value: unknown): MaybePromise<T> =>
3155
3345
  runParsed(value, ctx, finalize, wrapResponse)
3156
- return this.readBodyInput(source, finish, wrapResponse, (err) =>
3346
+ return this.readBodyInput(source, maxBodyBytes, finish, wrapResponse, (err) =>
3157
3347
  logError(err, ctx, wrapResponse),
3158
3348
  )
3159
3349
  }
@@ -3172,7 +3362,13 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3172
3362
  platform,
3173
3363
  nativeContext,
3174
3364
  (result, _set, ctx) =>
3175
- fusedRespond(result, ctx, this.responseBodyTag, this.staticResponseHeaders),
3365
+ fusedRespond(
3366
+ result,
3367
+ ctx,
3368
+ this.responseBodyTag,
3369
+ this.staticResponseHeaders,
3370
+ this.onResponseHooks.length === 0,
3371
+ ),
3176
3372
  this.wrapWebResponse,
3177
3373
  ) as MaybePromise<Response>
3178
3374
  }
@@ -3188,6 +3384,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3188
3384
  handler: InternalHandler,
3189
3385
  decorations: Record<PropertyKey, unknown> | undefined,
3190
3386
  querySchema: StandardSchemaV1,
3387
+ maxBodyBytes: number,
3191
3388
  ): FusedWebRunner {
3192
3389
  // The fused lanes return to the runtime directly, so the framework's own error renders fold in
3193
3390
  // static headers here rather than through the shared `wrapResponse` seam.
@@ -3206,16 +3403,38 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3206
3403
  }
3207
3404
  if (result instanceof Promise) {
3208
3405
  return result.then(
3209
- (settled) => fusedRespond(settled, ctx, this.responseBodyTag, this.staticResponseHeaders),
3406
+ (settled) =>
3407
+ fusedRespond(
3408
+ settled,
3409
+ ctx,
3410
+ this.responseBodyTag,
3411
+ this.staticResponseHeaders,
3412
+ this.onResponseHooks.length === 0,
3413
+ ),
3210
3414
  (err) => logError(err, ctx),
3211
3415
  )
3212
3416
  }
3213
- return fusedRespond(result, ctx, this.responseBodyTag, this.staticResponseHeaders)
3417
+ return fusedRespond(
3418
+ result,
3419
+ ctx,
3420
+ this.responseBodyTag,
3421
+ this.staticResponseHeaders,
3422
+ this.onResponseHooks.length === 0,
3423
+ )
3214
3424
  }
3215
3425
  return (source, params, search, signal, budget, platform, nativeContext) => {
3216
3426
  const ctx = nativeContext
3217
- ? RequestContext.native(source, params, search, this.maxBodyBytes, platform)
3218
- : new RequestContext(source, params, search, signal, budget, platform, this.maxBodyBytes)
3427
+ ? RequestContext.native(source, params, search, maxBodyBytes, platform, this.protoPoisoning)
3428
+ : new RequestContext(
3429
+ source,
3430
+ params,
3431
+ search,
3432
+ signal,
3433
+ budget,
3434
+ platform,
3435
+ maxBodyBytes,
3436
+ this.protoPoisoning,
3437
+ )
3219
3438
  if (decorations !== undefined) Object.assign(ctx, decorations)
3220
3439
  let validation: MaybePromise<StandardResult<unknown>>
3221
3440
  try {
@@ -3244,6 +3463,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3244
3463
  return wrapResponse(jsonError(500, "internal_error"))
3245
3464
  }
3246
3465
 
3466
+ // @ts-expect-error TS6133 -- invoked structurally by compiled plans (internal/route-execution.ts)
3467
+ // biome-ignore lint/correctness/noUnusedPrivateClassMembers: invoked structurally by compiled plans (internal/route-execution.ts)
3247
3468
  private runWithAround<T>(
3248
3469
  entry: RouteEntry,
3249
3470
  ctx: RawContext,
@@ -3280,6 +3501,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3280
3501
  return dispatch(0)
3281
3502
  }
3282
3503
 
3504
+ // @ts-expect-error TS6133 -- invoked structurally by compiled plans (internal/route-execution.ts)
3505
+ // biome-ignore lint/correctness/noUnusedPrivateClassMembers: invoked structurally by compiled plans (internal/route-execution.ts)
3283
3506
  private runBodyOnly<T>(
3284
3507
  entry: RouteEntry,
3285
3508
  source: RequestSource,
@@ -3289,6 +3512,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3289
3512
  ): Promise<T> {
3290
3513
  return this.readBodyInput(
3291
3514
  source,
3515
+ entry.bodyLimit ?? this.maxBodyBytes,
3292
3516
  (parsed) => this.finishBodyOnly(entry, parsed, ctx, finalize, wrapResponse),
3293
3517
  wrapResponse,
3294
3518
  (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse),
@@ -3299,6 +3523,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3299
3523
  * the validation + handler continuation differs. */
3300
3524
  private readBodyInput<T>(
3301
3525
  source: RequestSource,
3526
+ maxBodyBytes: number,
3302
3527
  onParsed: (parsed: unknown) => MaybePromise<T>,
3303
3528
  wrapResponse: (response: Response) => T,
3304
3529
  onError: (err: unknown) => MaybePromise<T>,
@@ -3306,7 +3531,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3306
3531
  const contentType = headerOf(source, "content-type") ?? ""
3307
3532
  if (contentType !== "application/json" && !contentType.includes("application/json")) {
3308
3533
  if (isUrlEncodedForm(contentType)) {
3309
- return readBoundedForm(source, this.maxBodyBytes).then(
3534
+ return readBoundedForm(source, maxBodyBytes).then(
3310
3535
  (form) => (form instanceof Response ? wrapResponse(form) : onParsed(form)),
3311
3536
  onError,
3312
3537
  ) as Promise<T>
@@ -3314,24 +3539,13 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3314
3539
  return Promise.resolve(wrapResponse(jsonError(415, "unsupported_media_type")))
3315
3540
  }
3316
3541
 
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
-
3542
+ // All JSON bodies go through readBoundedJson - the single enforcement point for the framed
3543
+ // fast path (post-read byte re-check), the streaming cap, and the prototype-poisoning guard.
3544
+ // A separate inlined native-json() fast path here would fork the trust boundary in two.
3333
3545
  try {
3334
- return this.readBoundedJson(source).then(
3546
+ return this.readBoundedJson(
3547
+ source,
3548
+ maxBodyBytes,
3335
3549
  (parsed) => (parsed instanceof Response ? wrapResponse(parsed) : onParsed(parsed)),
3336
3550
  onError,
3337
3551
  )
@@ -3487,6 +3701,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3487
3701
  return this.executeHandler(entry, ctx, finalize)
3488
3702
  }
3489
3703
 
3704
+ // @ts-expect-error TS6133 -- invoked structurally by compiled plans (internal/route-execution.ts)
3705
+ // biome-ignore lint/correctness/noUnusedPrivateClassMembers: invoked structurally by compiled plans (internal/route-execution.ts)
3490
3706
  private runQueryOnly<T>(
3491
3707
  entry: RouteEntry,
3492
3708
  ctx: RawContext,
@@ -3693,6 +3909,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3693
3909
  * node-direct primitives); an early/error `Response` (validation, thrown, 500) through `wrapResponse`.
3694
3910
  */
3695
3911
  /** Synchronous until a validator, lifecycle hook, handler, or contract check actually returns a Promise. */
3912
+ // @ts-expect-error TS6133 -- invoked structurally by compiled plans (internal/route-execution.ts)
3913
+ // biome-ignore lint/correctness/noUnusedPrivateClassMembers: invoked structurally by compiled plans (internal/route-execution.ts)
3696
3914
  private runLifecycle<T>(
3697
3915
  entry: RouteEntry,
3698
3916
  source: RequestSource,
@@ -3701,6 +3919,32 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3701
3919
  wrapResponse: (response: Response) => T,
3702
3920
  ): MaybePromise<T> {
3703
3921
  try {
3922
+ const headersSchema = entry.schema?.headers
3923
+ if (headersSchema !== undefined) {
3924
+ const validation = headersSchema["~standard"].validate(headerObjectOf(source.headers))
3925
+ if (validation instanceof Promise) {
3926
+ return validation.then(
3927
+ (result) =>
3928
+ this.runLifecycleAfterHeadersResult(
3929
+ entry,
3930
+ source,
3931
+ ctx,
3932
+ finalize,
3933
+ wrapResponse,
3934
+ result,
3935
+ ),
3936
+ (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse),
3937
+ )
3938
+ }
3939
+ return this.runLifecycleAfterHeadersResult(
3940
+ entry,
3941
+ source,
3942
+ ctx,
3943
+ finalize,
3944
+ wrapResponse,
3945
+ validation,
3946
+ )
3947
+ }
3704
3948
  const paramsSchema = entry.schema?.params
3705
3949
  if (paramsSchema !== undefined) {
3706
3950
  const validation = paramsSchema["~standard"].validate(ctx.params)
@@ -3740,6 +3984,64 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3740
3984
  }
3741
3985
  }
3742
3986
 
3987
+ private runLifecycleAfterHeadersResult<T>(
3988
+ entry: RouteEntry,
3989
+ source: RequestSource,
3990
+ ctx: RawContext,
3991
+ finalize: (result: unknown, set: CtxSet) => T,
3992
+ wrapResponse: (response: Response) => T,
3993
+ result: StandardResult<unknown>,
3994
+ ): MaybePromise<T> {
3995
+ try {
3996
+ const headersError = this.applyLifecycleValidation(entry, result, ctx, "headers")
3997
+ if (headersError instanceof Promise) {
3998
+ return headersError.then(
3999
+ (error) =>
4000
+ error === undefined
4001
+ ? this.runLifecycleAfterHeaders(entry, source, ctx, finalize, wrapResponse)
4002
+ : wrapResponse(error),
4003
+ (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse),
4004
+ )
4005
+ }
4006
+ return headersError === undefined
4007
+ ? this.runLifecycleAfterHeaders(entry, source, ctx, finalize, wrapResponse)
4008
+ : wrapResponse(headersError)
4009
+ } catch (err) {
4010
+ return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse)
4011
+ }
4012
+ }
4013
+
4014
+ private runLifecycleAfterHeaders<T>(
4015
+ entry: RouteEntry,
4016
+ source: RequestSource,
4017
+ ctx: RawContext,
4018
+ finalize: (result: unknown, set: CtxSet) => T,
4019
+ wrapResponse: (response: Response) => T,
4020
+ ): MaybePromise<T> {
4021
+ const paramsSchema = entry.schema?.params
4022
+ if (paramsSchema === undefined) {
4023
+ if (entry.schema?.body === undefined)
4024
+ return this.runQueryAndLifecycle(entry, ctx, finalize, wrapResponse)
4025
+ return this.runLifecycleAfterParams(entry, source, ctx, finalize, wrapResponse, undefined)
4026
+ }
4027
+ const validation = paramsSchema["~standard"].validate(ctx.params)
4028
+ if (validation instanceof Promise) {
4029
+ return validation.then(
4030
+ (result) =>
4031
+ this.runLifecycleAfterParamsResult(entry, source, ctx, finalize, wrapResponse, result),
4032
+ (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse),
4033
+ )
4034
+ }
4035
+ return this.runLifecycleAfterParamsResult(
4036
+ entry,
4037
+ source,
4038
+ ctx,
4039
+ finalize,
4040
+ wrapResponse,
4041
+ validation,
4042
+ )
4043
+ }
4044
+
3743
4045
  /** Registration-specialized query lifecycle: query validation (including recovery) → hooks. */
3744
4046
  private runLifecycleQuery<T>(
3745
4047
  entry: RouteEntry,
@@ -3772,6 +4074,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3772
4074
  }
3773
4075
 
3774
4076
  /** Registration-specialized body lifecycle: bounded body validation (including recovery) → hooks. */
4077
+ // @ts-expect-error TS6133 -- invoked structurally by compiled plans (internal/route-execution.ts)
4078
+ // biome-ignore lint/correctness/noUnusedPrivateClassMembers: invoked structurally by compiled plans (internal/route-execution.ts)
3775
4079
  private runLifecycleBody<T>(
3776
4080
  entry: RouteEntry,
3777
4081
  source: RequestSource,
@@ -3789,6 +4093,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3789
4093
  }
3790
4094
 
3791
4095
  /** Registration-specialized body + query lifecycle: bounded body validation → query validation → hooks. */
4096
+ // @ts-expect-error TS6133 -- invoked structurally by compiled plans (internal/route-execution.ts)
4097
+ // biome-ignore lint/correctness/noUnusedPrivateClassMembers: invoked structurally by compiled plans (internal/route-execution.ts)
3792
4098
  private runLifecycleBodyQuery<T>(
3793
4099
  entry: RouteEntry,
3794
4100
  source: RequestSource,
@@ -4070,8 +4376,12 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
4070
4376
  wrapResponse: (response: Response) => T,
4071
4377
  ): Promise<T> {
4072
4378
  try {
4073
- for (const hook of entry.beforeHandle) {
4074
- const outcome = hook(ctx)
4379
+ // Indexed, not `for...of`. An array iterator declared inside an async function stays live
4380
+ // across the `await` below, so neither engine can sink it - unlike the sync lanes above,
4381
+ // where V8 elides it. Measured cost of the iterator form: 43ns per hook on Bun, ~9ns on
4382
+ // Node, on a chain whose hooks return synchronously (the common shape).
4383
+ for (let i = 0; i < entry.beforeHandle.length; i++) {
4384
+ const outcome = entry.beforeHandle[i]!(ctx)
4075
4385
  const early = outcome instanceof Promise ? await outcome : outcome
4076
4386
  if (early !== undefined) return finalize(early, responseSet(ctx))
4077
4387
  }
@@ -4231,8 +4541,10 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
4231
4541
  // any handler or loader.
4232
4542
  if (err instanceof Response) return wrapResponse(err)
4233
4543
  // 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)
4544
+ // Indexed for the same reason as the `beforeHandle` chain above: the iterator would outlive
4545
+ // the `await` and stay heap-allocated on both engines.
4546
+ for (let i = 0; i < entry.onError.length; i++) {
4547
+ const outcome = entry.onError[i]!(err, ctx)
4236
4548
  const handled = outcome instanceof Promise ? await outcome : outcome
4237
4549
  if (handled !== undefined) return finalize(handled, responseSet(ctx))
4238
4550
  }
@@ -4245,6 +4557,11 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
4245
4557
  /** Log an unhandled request error to the (redacting) logger - shared by {@link runLifecycle} and the
4246
4558
  * bare fast path ({@link bareError}) so both record the same fields. Never throws; never leaks. */
4247
4559
  private logRequestError(err: unknown, ctx: RawContext): void {
4560
+ // An error's own text can quote the input that produced it, so how much of it reaches the sink is
4561
+ // the app's call (`errorLogDetail`). The default keeps it: a 500 with no message and no stack is
4562
+ // an incident nobody can diagnose, and the framework already ships the narrower instrument for
4563
+ // the leak - a redacting logger with `valuePatterns`. `"none"` is there for an untrusted sink.
4564
+ const detail = this.errorLogDetail
4248
4565
  this.logger.error("unhandled request error", {
4249
4566
  method: ctx.req.method,
4250
4567
  path: pathnameOf(ctx.req.url),
@@ -4252,8 +4569,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
4252
4569
  // `detail`, not `message`: the logger uses `message` for its own first argument, so a field of
4253
4570
  // that name is silently overwritten and the thrown error's own text never reaches the sink. It
4254
4571
  // 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,
4572
+ ...(detail === "none" ? {} : { detail: err instanceof Error ? err.message : String(err) }),
4573
+ ...(detail === "full" && err instanceof Error ? { stack: err.stack } : {}),
4257
4574
  })
4258
4575
  }
4259
4576
 
@@ -4265,11 +4582,11 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
4265
4582
  const contentType = headerOf(req, "content-type") ?? ""
4266
4583
  let parsed: unknown
4267
4584
  if (contentType === "application/json" || contentType.includes("application/json")) {
4268
- const json = await this.readBoundedJson(req)
4585
+ const json = await this.readBoundedJson(req, entry.bodyLimit ?? this.maxBodyBytes)
4269
4586
  if (json instanceof Response) return json
4270
4587
  parsed = json
4271
4588
  } else if (isUrlEncodedForm(contentType)) {
4272
- const form = await readBoundedForm(req, this.maxBodyBytes)
4589
+ const form = await readBoundedForm(req, entry.bodyLimit ?? this.maxBodyBytes)
4273
4590
  if (form instanceof Response) return form
4274
4591
  parsed = form
4275
4592
  } else {
@@ -4288,11 +4605,12 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
4288
4605
  entry: RouteEntry,
4289
4606
  result: StandardResult<unknown>,
4290
4607
  ctx: RawContext,
4291
- kind: "body" | "query" | "params",
4608
+ kind: "body" | "query" | "params" | "headers",
4292
4609
  ): MaybePromise<Response | undefined> {
4293
4610
  const assign = (value: unknown): void => {
4294
4611
  if (kind === "body") ctx.body = value
4295
4612
  else if (kind === "query") ctx.query = value
4613
+ else if (kind === "headers") ctx.headers = value as Record<string, string>
4296
4614
  else ctx.params = value as Record<string, string>
4297
4615
  }
4298
4616
  if (result.issues === undefined) {
@@ -4312,7 +4630,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
4312
4630
 
4313
4631
  private finishLifecycleValidationRecovery(
4314
4632
  entry: RouteEntry,
4315
- kind: "body" | "query" | "params",
4633
+ kind: "body" | "query" | "params" | "headers",
4316
4634
  issues: ReadonlyArray<StandardIssue>,
4317
4635
  recovery: unknown,
4318
4636
  assign: (value: unknown) => void,
@@ -4324,7 +4642,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
4324
4642
  ? entry.schema?.body
4325
4643
  : kind === "query"
4326
4644
  ? entry.schema?.query
4327
- : entry.schema?.params
4645
+ : kind === "params"
4646
+ ? entry.schema?.params
4647
+ : entry.schema?.headers
4328
4648
  const retried = schema!["~standard"].validate(recovery)
4329
4649
  if (retried instanceof Promise) {
4330
4650
  return retried.then((settled) => {
@@ -4339,21 +4659,30 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
4339
4659
  }
4340
4660
 
4341
4661
  /**
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.
4662
+ * Read + parse the JSON body, capped at `maxBodyBytes` and screened by the server's
4663
+ * `protoPoisoning` policy. Rejects a `Content-Length` over the cap *before* buffering,
4664
+ * re-checks what was actually delivered after the read, and aborts a chunked or
4665
+ * length-less body mid-stream once the running byte count exceeds the cap - so a lying
4666
+ * or absent length can't force us to buffer an oversized payload.
4354
4667
  */
4355
- private async readBoundedJson(req: RequestSource): Promise<unknown | Response> {
4356
- return readBoundedJsonSource(req, this.maxBodyBytes)
4668
+ private readBoundedJson(req: RequestSource, maxBodyBytes?: number): Promise<unknown | Response>
4669
+ private readBoundedJson<T>(
4670
+ req: RequestSource,
4671
+ maxBodyBytes: number,
4672
+ onResult: (parsed: unknown | Response) => T | Promise<T>,
4673
+ onError: (error: unknown) => T | Promise<T>,
4674
+ ): Promise<T>
4675
+ private readBoundedJson<T>(
4676
+ req: RequestSource,
4677
+ maxBodyBytes = this.maxBodyBytes,
4678
+ onResult?: (parsed: unknown | Response) => T | Promise<T>,
4679
+ onError?: (error: unknown) => T | Promise<T>,
4680
+ ): Promise<unknown | Response | T> {
4681
+ // Not `async`: this is a pass-through, and wrapping the lane's own promise in a coroutine
4682
+ // frame costs an extra microtask hop on every JSON body.
4683
+ return onResult === undefined
4684
+ ? readBoundedJsonSource(req, maxBodyBytes, this.protoPoisoning)
4685
+ : readBoundedJsonSource(req, maxBodyBytes, this.protoPoisoning, onResult, onError!)
4357
4686
  }
4358
4687
 
4359
4688
  /** Adapt one route's compiled execution plan to Bun's already-matched request shape. Route
@@ -4365,25 +4694,51 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
4365
4694
  signal: AbortSignal | undefined,
4366
4695
  budget: RequestBudget | undefined,
4367
4696
  ): BunNativeHandler {
4697
+ // This callback is reached only from Bun's compiled native route table. Bun has already parsed
4698
+ // the HTTP framing, so the JSON lane may retain its native fused `json()` parse without weakening
4699
+ // the portable/adapted source contract (those sources are never marked).
4700
+ const markFramed = (request: Request): void => markTrustedBodyFraming(request)
4701
+ // The fused native lane bypasses `runMatched`, so the route's transport byte cap must be
4702
+ // marked here too - otherwise a Bun `listen()` fused route would leave direct `c.req` body
4703
+ // reads uncapped. The non-fused branches go through `fetchMatched` -> `runMatched`, which marks.
4704
+ const bodyLimit = entry.bodyLimit
4705
+ const inner = fused
4706
+ const capped: FusedWebRunner | undefined =
4707
+ inner === undefined || bodyLimit === undefined
4708
+ ? inner
4709
+ : (source, params, search, fusedSignal, fusedBudget, platform, nativeContext) => {
4710
+ const method = source.method
4711
+ if (method !== "GET" && method !== "HEAD") markTransportCap(source, bodyLimit)
4712
+ return inner(source, params, search, fusedSignal, fusedBudget, platform, nativeContext)
4713
+ }
4368
4714
  if (paramNames.length === 0) {
4369
- if (fused === undefined) {
4370
- return (request) => this.fetchMatched(request, entry, EMPTY_PARAMS)
4715
+ if (capped === undefined) {
4716
+ return (request) => {
4717
+ markFramed(request)
4718
+ return this.fetchMatched(request, entry, EMPTY_PARAMS)
4719
+ }
4371
4720
  }
4372
4721
  if (this.acceptInboundDeadlines) {
4373
- return (request) =>
4374
- request.headers.get(NIFRA_DEADLINE_HEADER) !== null
4722
+ return (request) => {
4723
+ markFramed(request)
4724
+ return request.headers.get(NIFRA_DEADLINE_HEADER) !== null
4375
4725
  ? this.fetchMatched(request, entry, EMPTY_PARAMS)
4376
- : fused(request, EMPTY_PARAMS, undefined, signal!, budget!, undefined, true)
4726
+ : capped(request, EMPTY_PARAMS, undefined, signal!, budget!, undefined, true)
4727
+ }
4728
+ }
4729
+ return (request) => {
4730
+ markFramed(request)
4731
+ return capped(request, EMPTY_PARAMS, undefined, signal!, budget!, undefined, true)
4377
4732
  }
4378
- return (request) => fused(request, EMPTY_PARAMS, undefined, signal!, budget!, undefined, true)
4379
4733
  }
4380
4734
 
4381
4735
  const malformed =
4382
4736
  paramNames.length === 1
4383
4737
  ? (params: Record<string, string>) => params[paramNames[0]!]?.includes("\uFFFD") === true
4384
4738
  : hasReplacementParam
4385
- if (fused === undefined) {
4739
+ if (capped === undefined) {
4386
4740
  return (request) => {
4741
+ markFramed(request)
4387
4742
  const params = (request as BunRequestWithParams).params ?? EMPTY_PARAMS
4388
4743
  if (malformed(params)) return this.fetchSource(request)
4389
4744
  return this.fetchMatched(request, entry, params)
@@ -4391,17 +4746,19 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
4391
4746
  }
4392
4747
  if (this.acceptInboundDeadlines) {
4393
4748
  return (request) => {
4749
+ markFramed(request)
4394
4750
  const params = (request as BunRequestWithParams).params ?? EMPTY_PARAMS
4395
4751
  if (malformed(params)) return this.fetchSource(request)
4396
4752
  return request.headers.get(NIFRA_DEADLINE_HEADER) !== null
4397
4753
  ? this.fetchMatched(request, entry, params)
4398
- : fused(request, params, undefined, signal!, budget!, undefined, true)
4754
+ : capped(request, params, undefined, signal!, budget!, undefined, true)
4399
4755
  }
4400
4756
  }
4401
4757
  return (request) => {
4758
+ markFramed(request)
4402
4759
  const params = (request as BunRequestWithParams).params ?? EMPTY_PARAMS
4403
4760
  if (malformed(params)) return this.fetchSource(request)
4404
- return fused(request, params, undefined, signal!, budget!, undefined, true)
4761
+ return capped(request, params, undefined, signal!, budget!, undefined, true)
4405
4762
  }
4406
4763
  }
4407
4764
 
@@ -4476,10 +4833,21 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
4476
4833
  * `"127.0.0.1"` to bind loopback only - an admin surface, a sidecar, or anything that must not be
4477
4834
  * reachable off the box. Omitting it when you meant to restrict is a real exposure, so it is a
4478
4835
  * first-class option rather than something a caller has to drop down to `Bun.serve` for.
4836
+ *
4837
+ * `idleTimeoutSec` is how long Bun tolerates a connection with no bytes moving before it closes
4838
+ * the socket. Bun's default is **10 seconds**, and a request whose handler is still working sends
4839
+ * nothing - so any endpoint slower than 10s (a render, an export, a long upstream call) has its
4840
+ * connection cut mid-flight regardless of `requestTimeoutMs`. Apps with such endpoints must raise
4841
+ * this above their slowest expected response, which is why it is a first-class option and not a
4842
+ * reason to drop down to `Bun.serve`. `0` disables the timeout entirely; max 255.
4479
4843
  */
4480
4844
  listen(
4481
4845
  port: number,
4482
- options?: { readonly reusePort?: boolean; readonly hostname?: string },
4846
+ options?: {
4847
+ readonly reusePort?: boolean
4848
+ readonly hostname?: string
4849
+ readonly idleTimeoutSec?: number
4850
+ },
4483
4851
  ): RunningServer {
4484
4852
  if (typeof Bun === "undefined") {
4485
4853
  // listen() is the one Bun-specific seam. Off Bun, fail loud + actionable rather
@@ -4509,12 +4877,16 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
4509
4877
  // Spread rather than pass `hostname: undefined` - Bun treats an explicit undefined as a value
4510
4878
  // on some option paths, and omitting is what selects its 0.0.0.0 default.
4511
4879
  const bind = options?.hostname === undefined ? {} : { hostname: options.hostname }
4880
+ // Same reasoning as `bind`: omit rather than pass undefined, so Bun's own default applies.
4881
+ const idle =
4882
+ options?.idleTimeoutSec === undefined ? {} : { idleTimeout: options.idleTimeoutSec }
4512
4883
  const nativeRoutes = wsHandlers === undefined ? this.buildBunNativeRoutes() : undefined
4513
4884
  const running = (wsHandlers === undefined
4514
4885
  ? Bun.serve({
4515
4886
  port,
4516
4887
  reusePort,
4517
4888
  ...bind,
4889
+ ...idle,
4518
4890
  ...(nativeRoutes === undefined ? {} : { routes: nativeRoutes }),
4519
4891
  fetch: (req: Request, server) =>
4520
4892
  this.fetch(req, bunPeerPlatform(server, req) as Platform<EnvOf<Ctx>>),
@@ -4523,6 +4895,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
4523
4895
  port,
4524
4896
  reusePort,
4525
4897
  ...bind,
4898
+ ...idle,
4526
4899
  fetch: (req, server) => this.bunFetchWithWebSocket(req, server),
4527
4900
  // Bun's `ServerWebSocket<BunWsData>` is runtime-compatible with the handlers' structural
4528
4901
  // `BunSocket` view (kept local so `Bun.*` types never leak into the published .d.ts); the
@@ -4562,6 +4935,13 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
4562
4935
  await Bun.sleep(DRAIN_POLL_MS)
4563
4936
  }
4564
4937
  server.stop(server.pendingRequests > 0) // force-close iff stragglers remain past the deadline
4938
+ if (this.stopHooks.length === 0) return
4939
+ const callbacks = this.stopHooks.map((hook) => Promise.resolve().then(hook))
4940
+ const settled = Promise.allSettled(callbacks)
4941
+ await Promise.race([
4942
+ settled,
4943
+ new Promise<void>((resolve) => setTimeout(resolve, STOP_HOOK_TIMEOUT_MS)),
4944
+ ])
4565
4945
  }
4566
4946
 
4567
4947
  private installSignalHandlers(): void {