@nifrajs/core 2.11.0 → 2.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (180) hide show
  1. package/dist/assurance.d.ts +61 -3
  2. package/dist/assurance.d.ts.map +1 -1
  3. package/dist/assurance.js +152 -5
  4. package/dist/assurance.js.map +1 -1
  5. package/dist/capabilities.d.ts +9 -1
  6. package/dist/capabilities.d.ts.map +1 -1
  7. package/dist/capabilities.js +5 -1
  8. package/dist/capabilities.js.map +1 -1
  9. package/dist/diff.d.ts +2 -1
  10. package/dist/diff.d.ts.map +1 -1
  11. package/dist/diff.js +3 -1
  12. package/dist/diff.js.map +1 -1
  13. package/dist/errors.d.ts +1 -1
  14. package/dist/errors.d.ts.map +1 -1
  15. package/dist/errors.js.map +1 -1
  16. package/dist/evidence.d.ts +89 -0
  17. package/dist/evidence.d.ts.map +1 -0
  18. package/dist/evidence.js +170 -0
  19. package/dist/evidence.js.map +1 -0
  20. package/dist/execution-policy.d.ts +23 -0
  21. package/dist/execution-policy.d.ts.map +1 -0
  22. package/dist/execution-policy.js +28 -0
  23. package/dist/execution-policy.js.map +1 -0
  24. package/dist/index.d.ts +1 -1
  25. package/dist/index.js +1 -1
  26. package/dist/internal/route-assurance.d.ts +19 -0
  27. package/dist/internal/route-assurance.d.ts.map +1 -1
  28. package/dist/internal/route-assurance.js +38 -5
  29. package/dist/internal/route-assurance.js.map +1 -1
  30. package/dist/internal/route-execution.d.ts +18 -0
  31. package/dist/internal/route-execution.d.ts.map +1 -1
  32. package/dist/internal/route-execution.js +67 -1
  33. package/dist/internal/route-execution.js.map +1 -1
  34. package/dist/manifest.d.ts +5 -1
  35. package/dist/manifest.d.ts.map +1 -1
  36. package/dist/manifest.js +22 -4
  37. package/dist/manifest.js.map +1 -1
  38. package/dist/reflection.d.ts +5 -0
  39. package/dist/reflection.d.ts.map +1 -1
  40. package/dist/reflection.js +13 -4
  41. package/dist/reflection.js.map +1 -1
  42. package/dist/replay.d.ts +26 -0
  43. package/dist/replay.d.ts.map +1 -0
  44. package/dist/replay.js +63 -0
  45. package/dist/replay.js.map +1 -0
  46. package/dist/schema/standard.d.ts +2 -0
  47. package/dist/schema/standard.d.ts.map +1 -1
  48. package/dist/schema/standard.js +13 -0
  49. package/dist/schema/standard.js.map +1 -1
  50. package/dist/server/body.d.ts +49 -6
  51. package/dist/server/body.d.ts.map +1 -1
  52. package/dist/server/body.js +226 -8
  53. package/dist/server/body.js.map +1 -1
  54. package/dist/server/context.d.ts +20 -3
  55. package/dist/server/context.d.ts.map +1 -1
  56. package/dist/server/contract.d.ts +15 -3
  57. package/dist/server/contract.d.ts.map +1 -1
  58. package/dist/server/contract.js +22 -0
  59. package/dist/server/contract.js.map +1 -1
  60. package/dist/server/cookies.d.ts +26 -5
  61. package/dist/server/cookies.d.ts.map +1 -1
  62. package/dist/server/cookies.js +76 -9
  63. package/dist/server/cookies.js.map +1 -1
  64. package/dist/server/edge.d.ts.map +1 -1
  65. package/dist/server/edge.js +11 -0
  66. package/dist/server/edge.js.map +1 -1
  67. package/dist/server/header-case.d.ts +9 -0
  68. package/dist/server/header-case.d.ts.map +1 -0
  69. package/dist/server/header-case.js +54 -0
  70. package/dist/server/header-case.js.map +1 -0
  71. package/dist/server/headers.d.ts +8 -0
  72. package/dist/server/headers.d.ts.map +1 -0
  73. package/dist/server/headers.js +13 -0
  74. package/dist/server/headers.js.map +1 -0
  75. package/dist/server/node-outcome-hook.d.ts.map +1 -1
  76. package/dist/server/node-outcome-hook.js +7 -11
  77. package/dist/server/node-outcome-hook.js.map +1 -1
  78. package/dist/server/node-outcome.d.ts +8 -1
  79. package/dist/server/node-outcome.d.ts.map +1 -1
  80. package/dist/server/node-outcome.js +10 -3
  81. package/dist/server/node-outcome.js.map +1 -1
  82. package/dist/server/plugin.d.ts +66 -7
  83. package/dist/server/plugin.d.ts.map +1 -1
  84. package/dist/server/plugin.js +14 -10
  85. package/dist/server/plugin.js.map +1 -1
  86. package/dist/server/proto-guard.d.ts +30 -0
  87. package/dist/server/proto-guard.d.ts.map +1 -0
  88. package/dist/server/proto-guard.js +116 -0
  89. package/dist/server/proto-guard.js.map +1 -0
  90. package/dist/server/registry.d.ts +6 -2
  91. package/dist/server/registry.d.ts.map +1 -1
  92. package/dist/server/request-context.d.ts +24 -6
  93. package/dist/server/request-context.d.ts.map +1 -1
  94. package/dist/server/request-context.js +157 -30
  95. package/dist/server/request-context.js.map +1 -1
  96. package/dist/server/respond.d.ts +2 -2
  97. package/dist/server/respond.d.ts.map +1 -1
  98. package/dist/server/respond.js +34 -8
  99. package/dist/server/respond.js.map +1 -1
  100. package/dist/server/response-contract-lane.d.ts +1 -1
  101. package/dist/server/response-contract-lane.d.ts.map +1 -1
  102. package/dist/server/response-contract-lane.js +45 -13
  103. package/dist/server/response-contract-lane.js.map +1 -1
  104. package/dist/server/response-hooks.d.ts +10 -0
  105. package/dist/server/response-hooks.d.ts.map +1 -0
  106. package/dist/server/response-hooks.js +52 -0
  107. package/dist/server/response-hooks.js.map +1 -0
  108. package/dist/server/runtime-core.d.ts +12 -0
  109. package/dist/server/runtime-core.d.ts.map +1 -1
  110. package/dist/server/runtime-core.js +8 -0
  111. package/dist/server/runtime-core.js.map +1 -1
  112. package/dist/server/server-types.d.ts +60 -6
  113. package/dist/server/server-types.d.ts.map +1 -1
  114. package/dist/server/server.d.ts +80 -23
  115. package/dist/server/server.d.ts.map +1 -1
  116. package/dist/server/server.js +429 -213
  117. package/dist/server/server.js.map +1 -1
  118. package/dist/server/static-headers.d.ts +14 -3
  119. package/dist/server/static-headers.d.ts.map +1 -1
  120. package/dist/server/static-headers.js +28 -6
  121. package/dist/server/static-headers.js.map +1 -1
  122. package/dist/server/transport-lane.d.ts +8 -0
  123. package/dist/server/transport-lane.d.ts.map +1 -1
  124. package/dist/server/transport-lane.js +12 -8
  125. package/dist/server/transport-lane.js.map +1 -1
  126. package/dist/server/websocket.d.ts +3 -0
  127. package/dist/server/websocket.d.ts.map +1 -1
  128. package/dist/server/websocket.js +14 -0
  129. package/dist/server/websocket.js.map +1 -1
  130. package/dist/server.d.ts +2 -2
  131. package/dist/server.d.ts.map +1 -1
  132. package/dist/server.js +2 -2
  133. package/dist/server.js.map +1 -1
  134. package/dist/tool-catalog.d.ts +21 -0
  135. package/dist/tool-catalog.d.ts.map +1 -0
  136. package/dist/tool-catalog.js +16 -0
  137. package/dist/tool-catalog.js.map +1 -0
  138. package/dist/tool-contract.d.ts +238 -0
  139. package/dist/tool-contract.d.ts.map +1 -0
  140. package/dist/tool-contract.js +569 -0
  141. package/dist/tool-contract.js.map +1 -0
  142. package/package.json +26 -1
  143. package/src/assurance.ts +236 -6
  144. package/src/capabilities.ts +14 -1
  145. package/src/diff.ts +5 -2
  146. package/src/errors.ts +1 -0
  147. package/src/evidence.ts +319 -0
  148. package/src/execution-policy.ts +57 -0
  149. package/src/index.ts +1 -1
  150. package/src/internal/route-assurance.ts +67 -4
  151. package/src/internal/route-execution.ts +276 -1
  152. package/src/manifest.ts +27 -5
  153. package/src/reflection.ts +24 -4
  154. package/src/replay.ts +96 -0
  155. package/src/schema/standard.ts +16 -0
  156. package/src/server/body.ts +275 -7
  157. package/src/server/context.ts +23 -3
  158. package/src/server/contract.ts +43 -3
  159. package/src/server/cookies.ts +80 -10
  160. package/src/server/edge.ts +10 -0
  161. package/src/server/header-case.ts +54 -0
  162. package/src/server/headers.ts +11 -0
  163. package/src/server/node-outcome-hook.ts +6 -11
  164. package/src/server/node-outcome.ts +10 -2
  165. package/src/server/plugin.ts +87 -8
  166. package/src/server/proto-guard.ts +118 -0
  167. package/src/server/registry.ts +6 -2
  168. package/src/server/request-context.ts +242 -26
  169. package/src/server/respond.ts +35 -4
  170. package/src/server/response-contract-lane.ts +6 -17
  171. package/src/server/response-hooks.ts +51 -0
  172. package/src/server/runtime-core.ts +14 -0
  173. package/src/server/server-types.ts +66 -6
  174. package/src/server/server.ts +672 -287
  175. package/src/server/static-headers.ts +27 -3
  176. package/src/server/transport-lane.ts +19 -8
  177. package/src/server/websocket.ts +18 -2
  178. package/src/server.ts +7 -0
  179. package/src/tool-catalog.ts +40 -0
  180. package/src/tool-contract.ts +896 -0
@@ -3,69 +3,23 @@ import { FrameworkError, RouteConfigError } from "../errors.js";
3
3
  import { CAPABILITY_GUARD, createCapabilityGuard, DEFAULT_CAPABILITY_INTERCEPTOR_TIMEOUT_MS, normalizeRouteCapabilities, } from "../internal/capability-runtime.js";
4
4
  import { assuranceDeclarationsOf, assuranceEvidenceFor, NIFRA_ASSURANCE_IDS, validEvidenceId, } from "../internal/route-assurance.js";
5
5
  import { RouteCatalog } from "../internal/route-catalog.js";
6
+ import { compileRouteExecutionPlan, } from "../internal/route-execution.js";
6
7
  import { isSameOriginRequest } from "../internal/same-origin.js";
7
8
  import { compileRoutePattern, decodeRouteParams } from "../router/pattern.js";
8
9
  import { EMPTY_PARAMS, Router } from "../router/router.js";
9
- import { assertByteLimit, parseContentLength } from "./body.js";
10
+ import { assertByteLimit, markTransportCap, markTrustedBodyFraming } from "./body.js";
10
11
  import { resolveClientIp } from "./client-ip.js";
12
+ import { hasLowercaseHeaderKeysMark, headerKeysAllLowercase, markLowercaseHeaderKeys, } from "./header-case.js";
13
+ import { headerObjectOf } from "./headers.js";
11
14
  import { jsonError, pathnameOf, urlPartsOf } from "./http.js";
12
15
  import { withStaticNodeHeaders } from "./node-outcome.js";
13
16
  import { recordHeadersView, } from "./node-outcome-hook.js";
14
17
  import { isUrlEncodedForm, queryObjectOf, readBoundedForm, searchOf, } from "./query.js";
15
18
  import { RequestContext, readBoundedJsonSource } from "./request-context.js";
16
- import { applyStaticResponseHeaders, buildStaticResponseHeaders, fusedRespond, fusedRespondNoSet, knownMutableHeaders, markTaggedResponse, rememberMutableHeaders, taggedResponseBody, taggedResponseOwner, toResponse, } from "./respond.js";
19
+ import { applyStaticResponseHeaders, buildStaticResponseHeaders, fusedRespond, fusedRespondNoSet, knownMutableHeaders, rememberMutableHeaders, taggedResponseBody, toResponse, } from "./respond.js";
20
+ import { applyBodyReplacement, withReplacedBody } from "./response-hooks.js";
17
21
  import { CONTEXT_SEARCH, CONTEXT_SET, EMPTY_RESPONSE_CONTROLS, getNeverAbortSignal, getUnboundedRequestBudget, headerOf, requestOf, } from "./runtime-core.js";
18
22
  import { normalizeStaticResponseHeaders } from "./static-headers.js";
19
- function isBodylessStatus(status) {
20
- return status === 204 || status === 205 || status === 304;
21
- }
22
- /** Apply a body hook's return to the native response context (bytes, or a structured replacement). */
23
- function applyBodyReplacement(response, replaced) {
24
- if (replaced === undefined)
25
- return;
26
- if (typeof replaced === "string" || replaced instanceof Uint8Array) {
27
- response.body = isBodylessStatus(response.status) ? null : replaced;
28
- return;
29
- }
30
- if (replaced.body !== undefined)
31
- response.body = replaced.body;
32
- if (replaced.status !== undefined)
33
- response.status = replaced.status;
34
- if (isBodylessStatus(response.status))
35
- response.body = null;
36
- }
37
- /** Swap a tagged Response's body for a hook's replacement, re-tagging so later body hooks (and the
38
- * Node fallback's direct writer) see the new bytes; explicit lengths are dropped so framing is
39
- * re-derived from what actually ships. */
40
- function withReplacedBody(response, replaced) {
41
- if (replaced === undefined)
42
- return response;
43
- const originalBody = taggedResponseBody(response);
44
- let body;
45
- let status = response.status;
46
- if (typeof replaced === "string" || replaced instanceof Uint8Array) {
47
- body = replaced;
48
- }
49
- else {
50
- body = replaced.body !== undefined ? replaced.body : (originalBody ?? null);
51
- if (replaced.status !== undefined)
52
- status = replaced.status;
53
- if (body === (originalBody ?? null) && status === response.status) {
54
- return response;
55
- }
56
- }
57
- if (isBodylessStatus(status))
58
- body = null;
59
- const headers = new Headers(response.headers);
60
- headers.delete("content-length");
61
- const next = new Response(body, {
62
- status,
63
- headers,
64
- });
65
- if (body !== null)
66
- markTaggedResponse(next, body, taggedResponseOwner(response));
67
- return next;
68
- }
69
23
  const HEADER_MUTABILITY_PROBE = "x-nifra-header-probe";
70
24
  const GUARDED_RESPONSE_HEADERS = new WeakSet();
71
25
  /**
@@ -199,10 +153,11 @@ function requireMcpRuntime(runtime) {
199
153
  }
200
154
  // Plugin definers + their types now live in `./plugin.ts`; re-exported here so `.use()` callers and
201
155
  // existing importers keep resolving them from the server module.
202
- export { defineIdentityPlugin, definePlugin, defineRouterPlugin, } from "./plugin.js";
156
+ export { defineContextPlugin, defineIdentityPlugin, definePlugin, defineRouterPlugin, } from "./plugin.js";
203
157
  const DEFAULT_MAX_BODY_BYTES = 1_000_000;
204
158
  const DEFAULT_DRAIN_MS = 10_000;
205
159
  const DRAIN_POLL_MS = 10;
160
+ const STOP_HOOK_TIMEOUT_MS = 5_000;
206
161
  /** Same-origin check for a WebSocket handshake (CSWSH default). {@link isSameOriginRequest} is the one
207
162
  * owner, shared with the server-function mount in `@nifrajs/web` - the two used to answer differently
208
163
  * for the same request, so a browser that could open a socket was told its POST was cross-origin. */
@@ -227,6 +182,31 @@ function hasReplacementParam(params) {
227
182
  }
228
183
  return false;
229
184
  }
185
+ function normalizeMountPrefix(path) {
186
+ if (!path.startsWith("/") || path.includes("?") || path.includes("#")) {
187
+ throw new TypeError("mountFetch path must be an absolute pathname without a query or hash");
188
+ }
189
+ const withoutWildcard = path.endsWith("/*") ? path.slice(0, -2) : path;
190
+ if (withoutWildcard.includes("*") || withoutWildcard.includes(":")) {
191
+ throw new TypeError("mountFetch path must be a literal prefix, optionally ending in /*");
192
+ }
193
+ if (withoutWildcard.length === 0)
194
+ return "/";
195
+ return withoutWildcard.length > 1 && withoutWildcard.endsWith("/")
196
+ ? withoutWildcard.slice(0, -1)
197
+ : withoutWildcard;
198
+ }
199
+ function underMountPrefix(pathname, prefix) {
200
+ return prefix === "/" || pathname === prefix || pathname.startsWith(`${prefix}/`);
201
+ }
202
+ function stripMountPrefix(request, prefix) {
203
+ if (prefix === "/")
204
+ return request;
205
+ const url = new URL(request.url);
206
+ const rest = url.pathname.slice(prefix.length);
207
+ url.pathname = rest === "" ? "/" : rest;
208
+ return new Request(url.href, request);
209
+ }
230
210
  function responseSet(ctx) {
231
211
  return ctx[CONTEXT_SET]() ?? EMPTY_RESPONSE_CONTROLS;
232
212
  }
@@ -261,6 +241,9 @@ export class Server {
261
241
  * no-WebSocket app never constructs (or bundles) it. */
262
242
  topics;
263
243
  maxBodyBytes;
244
+ protoPoisoning;
245
+ /** `trustBodyFraming`: mark every `app.fetch` request as runtime-framed (see ServerOptions). */
246
+ trustBodyFraming;
264
247
  wsMaxPayloadBytes;
265
248
  requestTimeoutMs;
266
249
  /** Installed by the `responseContract()` plugin; `undefined` = not installed, which is the default
@@ -272,6 +255,8 @@ export class Server {
272
255
  maxInboundDeadlineMs;
273
256
  deadlineAdmissionOptions;
274
257
  gracefulSignals;
258
+ stopHooks;
259
+ fetchMounts;
275
260
  /** Capacity-admission gate; `undefined` = off (the request path pays nothing). */
276
261
  capacityGate;
277
262
  onCapabilityUse;
@@ -292,6 +277,8 @@ export class Server {
292
277
  /** Installed WebSocket runtime for `.ws()` routes; `undefined` until `.use(websocket())`. */
293
278
  wsRuntime;
294
279
  logger;
280
+ /** How much of an unhandled error {@link logRequestError} records. See `ServerOptions.errorLogDetail`. */
281
+ errorLogDetail;
295
282
  /** App-wide validation-error fallback; a route's own `schema.onValidationError` takes precedence. */
296
283
  defaultOnValidationError;
297
284
  bunServer;
@@ -310,6 +297,13 @@ export class Server {
310
297
  onNodeResponseHooks;
311
298
  /** Registration-time eligibility for the Node response twin lane. */
312
299
  nodeResponseHooksComplete;
300
+ /**
301
+ * True once a bundle registers a RAW `onNodeResponse` twin - one handed the outcome's header
302
+ * record itself rather than the case-normalizing view. Their names are contractually the wire
303
+ * spelling, but registration cannot PROVE it, so such an app forgoes the all-lowercase mark and
304
+ * every reader re-derives the answer, exactly as before the mark existed.
305
+ */
306
+ hasRawNodeResponseHook;
313
307
  onResponseFinalizedHooks;
314
308
  /**
315
309
  * Statically declared response headers, merged and prebuilt at registration; `undefined` (the
@@ -347,6 +341,8 @@ export class Server {
347
341
  this.topics = undefined;
348
342
  const maxBodyBytes = options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES;
349
343
  assertByteLimit(maxBodyBytes, "maxBodyBytes");
344
+ this.protoPoisoning = options.protoPoisoning ?? "reject";
345
+ this.trustBodyFraming = options.trustBodyFraming ?? false;
350
346
  const wsMaxPayloadBytes = options.wsMaxPayloadBytes ?? maxBodyBytes;
351
347
  assertByteLimit(wsMaxPayloadBytes, "wsMaxPayloadBytes");
352
348
  this.maxBodyBytes = maxBodyBytes;
@@ -366,6 +362,8 @@ export class Server {
366
362
  throw new RangeError("maxInboundDeadlineMs must be a finite positive number");
367
363
  }
368
364
  this.gracefulSignals = options.gracefulSignals ?? false;
365
+ this.stopHooks = [];
366
+ this.fetchMounts = [];
369
367
  this.capacityGate = options.admission;
370
368
  this.onCapabilityUse = options.onCapabilityUse;
371
369
  this.capabilityInterceptors = [];
@@ -378,6 +376,7 @@ export class Server {
378
376
  this.responseContractRuntime = undefined;
379
377
  this.idempotencyRuntime = undefined;
380
378
  this.logger = options.logger ?? jsonLogger();
379
+ this.errorLogDetail = options.errorLogDetail ?? "full";
381
380
  this.defaultOnValidationError = options.onValidationError;
382
381
  this.bunServer = undefined;
383
382
  this.sealed = false;
@@ -393,6 +392,7 @@ export class Server {
393
392
  this.onResponseHooks = [];
394
393
  this.onNodeResponseHooks = [];
395
394
  this.nodeResponseHooksComplete = true;
395
+ this.hasRawNodeResponseHook = false;
396
396
  this.onResponseFinalizedHooks = [];
397
397
  this.staticResponseHeaders = undefined;
398
398
  this.wrapWebResponse = IDENTITY_RESPONSE;
@@ -413,6 +413,32 @@ export class Server {
413
413
  throw new FrameworkError("SERVER_SEALED", `server configuration is sealed after listen(); call ${operation} before listen()`);
414
414
  }
415
415
  }
416
+ /** Register a callback awaited after graceful drain and server stop. Hung callbacks are bounded. */
417
+ onStop(fn) {
418
+ this.assertConfigurable("onStop()");
419
+ if (typeof fn !== "function")
420
+ throw new TypeError("onStop callback must be a function");
421
+ this.stopHooks.push(fn);
422
+ return this;
423
+ }
424
+ /**
425
+ * Mount a legacy fetch handler under a literal path prefix. Mounted handlers sit behind typed
426
+ * routes, so routes can move one at a time while the remaining legacy surface stays live.
427
+ * Mounted responses are outside Nifra's typed route and response-contract checks.
428
+ */
429
+ mountFetch(path, handler, options = {}) {
430
+ this.assertConfigurable("mountFetch()");
431
+ if (typeof handler !== "function")
432
+ throw new TypeError("mountFetch handler must be a function");
433
+ const mount = {
434
+ path: normalizeMountPrefix(path),
435
+ handler: handler,
436
+ stripPrefix: options.stripPrefix === true,
437
+ };
438
+ this.fetchMounts.push(mount);
439
+ this.fetchMounts.sort((a, b) => b.path.length - a.path.length);
440
+ return this;
441
+ }
416
442
  /** Add a per-request, computed context extension for subsequent routes. */
417
443
  derive(fn) {
418
444
  this.assertConfigurable("derive()");
@@ -672,6 +698,8 @@ export class Server {
672
698
  this.onNodeResponseHooks.push(arg.onNodeResponse);
673
699
  if (arg.onNodeResponse === undefined)
674
700
  this.nodeResponseHooksComplete = false;
701
+ else
702
+ this.hasRawNodeResponseHook = true;
675
703
  }
676
704
  else if (arg.onNodeResponse !== undefined) {
677
705
  throw new TypeError("onNodeResponse() requires a paired onResponse() hook");
@@ -844,6 +872,29 @@ export class Server {
844
872
  }
845
873
  prepareRoute(method, path, schema, handler) {
846
874
  const pattern = compileRoutePattern(path);
875
+ let bodyLimit = this.maxBodyBytes;
876
+ const invalidBodyLimit = (message) => {
877
+ throw new RouteConfigError("INVALID_BODY_LIMIT", `route ${method} ${path}: ${message}`);
878
+ };
879
+ if (schema?.bodyLimitReason !== undefined && schema.bodyLimit !== "unlimited") {
880
+ invalidBodyLimit('bodyLimitReason is only valid with bodyLimit: "unlimited"');
881
+ }
882
+ if (schema?.bodyLimit === "unlimited") {
883
+ if (typeof schema.bodyLimitReason !== "string" ||
884
+ schema.bodyLimitReason.trim().length === 0) {
885
+ invalidBodyLimit('bodyLimit: "unlimited" requires a non-empty bodyLimitReason');
886
+ }
887
+ bodyLimit = undefined;
888
+ }
889
+ else if (schema?.bodyLimit !== undefined) {
890
+ try {
891
+ assertByteLimit(schema.bodyLimit, "route bodyLimit");
892
+ }
893
+ catch {
894
+ invalidBodyLimit('bodyLimit must be a non-negative safe integer or "unlimited"');
895
+ }
896
+ bodyLimit = schema.bodyLimit;
897
+ }
847
898
  const capabilities = normalizeRouteCapabilities(schema?.capabilities);
848
899
  const handlerAssurance = assuranceDeclarationsOf(handler);
849
900
  const invalidHandlerScope = handlerAssurance.find((declaration) => declaration.scope !== "plugin");
@@ -879,6 +930,7 @@ export class Server {
879
930
  ? { runtime, schema: schema.response }
880
931
  : undefined;
881
932
  const bare = schema?.params === undefined &&
933
+ schema?.headers === undefined &&
882
934
  schema?.body === undefined &&
883
935
  schema?.query === undefined &&
884
936
  idempotent === undefined &&
@@ -899,6 +951,7 @@ export class Server {
899
951
  schema?.query !== undefined &&
900
952
  schema.body === undefined &&
901
953
  schema.params === undefined &&
954
+ schema.headers === undefined &&
902
955
  schema.onValidationError === undefined &&
903
956
  this.defaultOnValidationError === undefined &&
904
957
  idempotent === undefined &&
@@ -912,6 +965,7 @@ export class Server {
912
965
  schema?.body !== undefined &&
913
966
  schema.query === undefined &&
914
967
  schema.params === undefined &&
968
+ schema.headers === undefined &&
915
969
  this.derives.length === 0 &&
916
970
  this.beforeHandleHooks.length === 0 &&
917
971
  this.afterHandleHooks.length === 0 &&
@@ -927,12 +981,12 @@ export class Server {
927
981
  // handler continuation once. The runner keeps the bounded parser and all error semantics while
928
982
  // avoiding the generic entry/schema/lifecycle dispatch on Bun, Deno, and Node-direct.
929
983
  const fusedBodyRunner = fusedBody
930
- ? this.buildFusedBodyRunner(handler, schema.body, hasDecorations ? routeDecorations : undefined)
984
+ ? this.buildFusedBodyRunner(handler, schema.body, hasDecorations ? routeDecorations : undefined, bodyLimit ?? this.maxBodyBytes)
931
985
  : undefined;
932
986
  const fusedWeb = bare && this.aroundHooks.length === 0
933
- ? this.buildFusedWeb(handler, hasDecorations ? routeDecorations : undefined, isContextlessNoArgArrow(handler))
987
+ ? this.buildFusedWeb(handler, hasDecorations ? routeDecorations : undefined, isContextlessNoArgArrow(handler), bodyLimit ?? this.maxBodyBytes)
934
988
  : fusedQuery
935
- ? this.buildFusedQueryWeb(handler, hasDecorations ? routeDecorations : undefined, schema.query)
989
+ ? this.buildFusedQueryWeb(handler, hasDecorations ? routeDecorations : undefined, schema.query, bodyLimit ?? this.maxBodyBytes)
936
990
  : fusedBody
937
991
  ? this.buildFusedBodyWeb(fusedBodyRunner)
938
992
  : undefined;
@@ -947,6 +1001,7 @@ export class Server {
947
1001
  schema?.body === undefined &&
948
1002
  schema?.query !== undefined &&
949
1003
  schema.params === undefined &&
1004
+ schema.headers === undefined &&
950
1005
  this.derives.length === 0 &&
951
1006
  this.beforeHandleHooks.length === 0 &&
952
1007
  this.afterHandleHooks.length === 0 &&
@@ -957,7 +1012,7 @@ export class Server {
957
1012
  // plus an optional query or body schema. Select their complete validation stage at registration
958
1013
  // so the request path never re-checks params/body presence. Parameter-schema routes retain the
959
1014
  // generic lifecycle runner until their more involved recovery matrix is selected explicitly.
960
- const lifecycleLane = lane !== "lifecycle" || schema?.params !== undefined
1015
+ const lifecycleLane = lane !== "lifecycle" || schema?.params !== undefined || schema?.headers !== undefined
961
1016
  ? undefined
962
1017
  : schema?.body !== undefined
963
1018
  ? schema.query !== undefined
@@ -979,12 +1034,22 @@ export class Server {
979
1034
  this.onErrorHooks.length === 0
980
1035
  ? "derive-before"
981
1036
  : undefined;
982
- const execution = this.compileExecutionPlan(lane, contextless, this.aroundHooks.length > 0, ledgered !== undefined, lifecycleLane, fusedWeb, fusedBodyRunner, fusedWeb === undefined ? undefined : fusedQuery ? "query" : fusedBody ? "body" : "bare");
1037
+ const execution = compileRouteExecutionPlan({
1038
+ lane,
1039
+ contextless,
1040
+ hasAround: this.aroundHooks.length > 0,
1041
+ hasLedger: ledgered !== undefined,
1042
+ lifecycleLane,
1043
+ fusedWeb,
1044
+ fusedBody: fusedBodyRunner,
1045
+ fusedLane: fusedWeb === undefined ? undefined : fusedQuery ? "query" : fusedBody ? "body" : "bare",
1046
+ });
983
1047
  const registeredEntry = {
984
1048
  // (context: never) => unknown -> InternalHandler: the framework invokes it
985
1049
  // with the concrete RawContext the typed handler expects, so this is sound.
986
1050
  handler: handler,
987
1051
  schema,
1052
+ bodyLimit,
988
1053
  idempotent,
989
1054
  ledgered,
990
1055
  responseContract: contracted,
@@ -1006,6 +1071,13 @@ export class Server {
1006
1071
  ...(schema?.family === true ? { family: true } : {}),
1007
1072
  };
1008
1073
  const routeAssurance = [...this.activeAssurance, ...handlerAssurance];
1074
+ if (contracted?.runtime.mode === "enforce" && schema?.response !== undefined) {
1075
+ routeAssurance.push(Object.freeze({
1076
+ id: NIFRA_ASSURANCE_IDS.RESPONSE_CONTRACT,
1077
+ source: "response-contract",
1078
+ scope: "plugin",
1079
+ }));
1080
+ }
1009
1081
  // Inline `schema.assurance`: the route DECLARES its enforcement evidence adjacent to the handler, so an
1010
1082
  // in-handler-guarded route satisfies a policy `require:` clause without a `withRouteAssurance` middleware
1011
1083
  // rewrite. Each id becomes route-scoped `declared` evidence (invalid ids fail closed at registration).
@@ -1013,7 +1085,14 @@ export class Server {
1013
1085
  if (!validEvidenceId(id)) {
1014
1086
  throw new Error(`route assurance: invalid evidence id ${JSON.stringify(id)} on ${method} ${path} (use lowercase dot/dash segments)`);
1015
1087
  }
1016
- routeAssurance.push(Object.freeze({ id, source: "declared", scope: "plugin" }));
1088
+ const declared = { id, source: "declared", scope: "plugin" };
1089
+ Object.defineProperty(declared, "provenance", {
1090
+ value: "declared",
1091
+ enumerable: false,
1092
+ configurable: false,
1093
+ writable: false,
1094
+ });
1095
+ routeAssurance.push(Object.freeze(declared));
1017
1096
  }
1018
1097
  if (schema?.body !== undefined) {
1019
1098
  routeAssurance.push(Object.freeze({
@@ -1040,67 +1119,6 @@ export class Server {
1040
1119
  assurance: Object.freeze(routeAssurance),
1041
1120
  };
1042
1121
  }
1043
- /** Collapse route-invariant lifecycle decisions into one runner at registration. The request path
1044
- * performs no eligibility ladder: it supplies request state to this already-selected plan. */
1045
- compileExecutionPlan(lane, contextless, hasAround, hasLedger, lifecycleLane, fusedWeb, fusedBody, fusedLane) {
1046
- if (contextless) {
1047
- const run = (runtime, entry, source, params, search, signal, budget, platform, _nativeContext, finalize, wrapResponse) => runtime.runContextlessBare(entry, source, params, search, signal, budget, platform, finalize, wrapResponse);
1048
- return Object.freeze({ run, fusedWeb, fusedBody, fusedLane });
1049
- }
1050
- let inner;
1051
- switch (lane) {
1052
- case "bare":
1053
- inner = (runtime, entry, _source, ctx, finalize, wrapResponse) => runtime.runBare(entry, ctx, finalize, wrapResponse);
1054
- break;
1055
- case "body":
1056
- inner = (runtime, entry, source, ctx, finalize, wrapResponse) => runtime.runBodyOnly(entry, source, ctx, finalize, wrapResponse);
1057
- break;
1058
- case "query":
1059
- inner = (runtime, entry, _source, ctx, finalize, wrapResponse) => runtime.runQueryOnly(entry, ctx, finalize, wrapResponse);
1060
- break;
1061
- default:
1062
- switch (lifecycleLane) {
1063
- case "hooks":
1064
- inner = (runtime, entry, _source, ctx, finalize, wrapResponse) => runtime.runLifecycleHooks(entry, ctx, finalize, wrapResponse);
1065
- break;
1066
- case "query":
1067
- inner = (runtime, entry, _source, ctx, finalize, wrapResponse) => runtime.runLifecycleQuery(entry, ctx, finalize, wrapResponse);
1068
- break;
1069
- case "body":
1070
- inner = (runtime, entry, source, ctx, finalize, wrapResponse) => runtime.runLifecycleBody(entry, source, ctx, finalize, wrapResponse);
1071
- break;
1072
- case "body-query":
1073
- inner = (runtime, entry, source, ctx, finalize, wrapResponse) => runtime.runLifecycleBodyQuery(entry, source, ctx, finalize, wrapResponse);
1074
- break;
1075
- default:
1076
- inner = (runtime, entry, source, ctx, finalize, wrapResponse) => runtime.runLifecycle(entry, source, ctx, finalize, wrapResponse);
1077
- }
1078
- }
1079
- const execute = hasAround
1080
- ? (runtime, entry, source, ctx, finalize, wrapResponse) => runtime.runWithAround(entry, ctx, () => inner(runtime, entry, source, ctx, finalize, wrapResponse), finalize, wrapResponse)
1081
- : inner;
1082
- const run = (runtime, entry, source, params, search, signal, budget, platform, nativeContext, finalize, wrapResponse) => {
1083
- const ctx = nativeContext
1084
- ? RequestContext.native(source, params, search, runtime.maxBodyBytes, platform)
1085
- : new RequestContext(source, params, search, signal, budget, platform, runtime.maxBodyBytes);
1086
- let ledger;
1087
- // The runtime is always present when a route resolved a ledger (enforced at registration).
1088
- const ledgerRuntime = runtime.effectLedgerRuntime;
1089
- if (hasLedger && ledgerRuntime !== undefined) {
1090
- const resolved = entry.ledgered;
1091
- ledger = ledgerRuntime.create(resolved);
1092
- ledgerRuntime.attach(ctx, ledger);
1093
- }
1094
- let outcome = execute(runtime, entry, source, ctx, finalize, wrapResponse);
1095
- if (ledger !== undefined && ledgerRuntime !== undefined) {
1096
- const active = ledger;
1097
- const resolved = entry.ledgered;
1098
- outcome = (outcome instanceof Promise ? outcome : Promise.resolve(outcome)).then((value) => ledgerRuntime.settle(active, resolved, value, (fields) => runtime.logger.error("effect ledger sink failed", fields)));
1099
- }
1100
- return outcome;
1101
- };
1102
- return Object.freeze({ run, fusedWeb, fusedBody, fusedLane });
1103
- }
1104
1122
  /** The idempotency lane's bridge back into the normal matched lanes, resolved to a concrete Response
1105
1123
  * (the lane buffers the body, then replays it through the route's real validation + handler). */
1106
1124
  idempotencyRunLanes(buffered, platform, entry, params, search) {
@@ -1148,10 +1166,15 @@ export class Server {
1148
1166
  *
1149
1167
  * Semantics: merged routes keep the chains captured where they were DEFINED - the group's
1150
1168
  * `derive`/`decorate`/`beforeHandle`/`afterHandle`/`onError`/`around` apply to its routes
1151
- * exactly as they did standalone, so a group wires its own plugins. The group's request-level
1152
- * hooks (`onRequest`/`onResponse`/`onResponseFinalized`) are appended to this server's. This
1153
- * server's route-scoped chains do NOT retroactively wrap merged routes (order-scoped, like
1154
- * routes registered before a `derive`). Fail closed: a path+method collision throws
1169
+ * exactly as they did standalone, so a group wires its own plugins. The same locality rule
1170
+ * covers the group's `onRequest` hooks: they run only for requests that route to the GROUP's
1171
+ * routes (a `bodyLimit()` mounted on an uploads group must not start gating the whole app
1172
+ * because the app composed it in), and the group's global assurance follows its hooks onto
1173
+ * exactly those routes. A group with hooks but NO routes is a middleware bundle - its hooks
1174
+ * can only mean app-wide intent, so they are appended globally, unchanged. Response-side hooks
1175
+ * (`onResponse`/`onResponseFinalized`) are appended to this server's. This server's
1176
+ * route-scoped chains do NOT retroactively wrap merged routes (order-scoped, like routes
1177
+ * registered before a `derive`). Fail closed: a path+method collision throws
1155
1178
  * `RouteConfigError` at merge time, and a group with WebSocket routes is refused (register
1156
1179
  * those on the parent).
1157
1180
  */
@@ -1161,7 +1184,20 @@ export class Server {
1161
1184
  if (source.wsRouteCount > 0) {
1162
1185
  throw new RouteConfigError("INVALID_PATH", "merge() does not carry WebSocket routes - register .ws() routes on the parent server");
1163
1186
  }
1164
- this.catalog.addBatch(source.catalog.entries().map((route) => this.bindFusedRuntime(route)));
1187
+ const sourceRoutes = source.catalog.entries();
1188
+ // Hooks and global assurance stay LOCAL to a group that has routes; a route-less group is a
1189
+ // middleware bundle whose hooks can only mean app-wide intent, so it keeps the global append.
1190
+ const scoped = sourceRoutes.length > 0;
1191
+ // A group's global assurance rides its (route-scoped) hooks: folded into each merged route's
1192
+ // own evidence rather than the parent's global list, so `routes()` never claims the group's
1193
+ // enforcement for parent routes the group's hooks do not see.
1194
+ const foldedAssurance = scoped && source.globalAssurance.length > 0
1195
+ ? (route) => ({
1196
+ ...route,
1197
+ assurance: [...route.assurance, ...source.globalAssurance],
1198
+ })
1199
+ : (route) => route;
1200
+ this.catalog.addBatch(sourceRoutes.map((route) => this.bindFusedRuntime(foldedAssurance(route))));
1165
1201
  // Resolved idempotency/ledger route entries carry their own store/sink configuration, while the
1166
1202
  // runtime object supplies the generic execution machinery. Preserve a group's installed runtime
1167
1203
  // when the parent has none so merging cannot silently disable a safety lane. If the parent already
@@ -1174,8 +1210,23 @@ export class Server {
1174
1210
  this.nodeOutcomeRuntime ??= source.nodeOutcomeRuntime;
1175
1211
  this.sseRuntime ??= source.sseRuntime;
1176
1212
  this.wsRuntime ??= source.wsRuntime;
1177
- this.onRequestHooks.push(...source.onRequestHooks);
1178
- this.onNodeRequestHooks.push(...source.onNodeRequestHooks);
1213
+ if (scoped && source.onRequestHooks.length > 0) {
1214
+ // Snapshot the group's routes into a dedicated matcher: the guard must reflect what was
1215
+ // MERGED, not whatever the group's own catalog grows into afterwards. One probe against it
1216
+ // gates each group hook to requests the group would serve; everything else passes untouched.
1217
+ const scope = new RouteCatalog();
1218
+ scope.addBatch(sourceRoutes);
1219
+ this.onRequestHooks.push(...source.onRequestHooks.map((hook) => (req, platform) => scope.find(req.method, pathnameOf(req.url)).found ? hook(req, platform) : undefined));
1220
+ // An `undefined` slot marks an unpaired hook (position-aligned with `onRequestHooks`) and
1221
+ // must stay `undefined` - wrapping it would fabricate a Node twin that never existed.
1222
+ this.onNodeRequestHooks.push(...source.onNodeRequestHooks.map((hook) => hook === undefined
1223
+ ? undefined
1224
+ : (req, platform) => scope.find(req.method, pathnameOf(req.url)).found ? hook(req, platform) : undefined));
1225
+ }
1226
+ else {
1227
+ this.onRequestHooks.push(...source.onRequestHooks);
1228
+ this.onNodeRequestHooks.push(...source.onNodeRequestHooks);
1229
+ }
1179
1230
  this.nodeRequestHooksComplete &&= source.nodeRequestHooksComplete;
1180
1231
  // The group's static declarations came before its own response hooks, so they are folded in
1181
1232
  // first - and fold themselves into a hook here if this server already has one (same ordering
@@ -1186,13 +1237,15 @@ export class Server {
1186
1237
  this.onResponseHooks.push(...source.onResponseHooks);
1187
1238
  this.onNodeResponseHooks.push(...source.onNodeResponseHooks);
1188
1239
  this.nodeResponseHooksComplete &&= source.nodeResponseHooksComplete;
1240
+ this.hasRawNodeResponseHook ||= source.hasRawNodeResponseHook;
1189
1241
  this.onResponseFinalizedHooks.push(...source.onResponseFinalizedHooks);
1190
1242
  if (source.responseBodyTag !== undefined) {
1191
1243
  const owner = this.enableResponseBodyTagging();
1192
1244
  this.responseBodyOwners.add(source.responseBodyTag);
1193
1245
  source.responseBodyOwners.add(owner);
1194
1246
  }
1195
- this.globalAssurance.push(...source.globalAssurance);
1247
+ if (!scoped)
1248
+ this.globalAssurance.push(...source.globalAssurance);
1196
1249
  this.mcpResourceList.push(...source.mcpResourceList);
1197
1250
  this.mcpPromptList.push(...source.mcpPromptList);
1198
1251
  return this;
@@ -1206,13 +1259,13 @@ export class Server {
1206
1259
  // Rebuild with the SAME builder that produced the closure - rebinding a query-fused route as
1207
1260
  // bare would silently drop its validation.
1208
1261
  const fusedBody = entry.execution.fusedLane === "body"
1209
- ? this.buildFusedBodyRunner(entry.handler, entry.schema?.body, entry.hasDecorations ? entry.decorations : undefined)
1262
+ ? this.buildFusedBodyRunner(entry.handler, entry.schema?.body, entry.hasDecorations ? entry.decorations : undefined, entry.bodyLimit ?? this.maxBodyBytes)
1210
1263
  : undefined;
1211
1264
  const fusedWeb = entry.execution.fusedLane === "body"
1212
1265
  ? this.buildFusedBodyWeb(fusedBody)
1213
1266
  : entry.execution.fusedLane === "query"
1214
- ? this.buildFusedQueryWeb(entry.handler, entry.hasDecorations ? entry.decorations : undefined, entry.schema?.query)
1215
- : this.buildFusedWeb(entry.handler, entry.hasDecorations ? entry.decorations : undefined, isContextlessNoArgArrow(entry.handler));
1267
+ ? this.buildFusedQueryWeb(entry.handler, entry.hasDecorations ? entry.decorations : undefined, entry.schema?.query, entry.bodyLimit ?? this.maxBodyBytes)
1268
+ : this.buildFusedWeb(entry.handler, entry.hasDecorations ? entry.decorations : undefined, isContextlessNoArgArrow(entry.handler), entry.bodyLimit ?? this.maxBodyBytes);
1216
1269
  return {
1217
1270
  ...route,
1218
1271
  entry: {
@@ -1243,6 +1296,10 @@ export class Server {
1243
1296
  * Bun/Node/Deno omit it (then `c.env` is `undefined` and `c.waitUntil` runs fire-and-forget).
1244
1297
  */
1245
1298
  fetch(req, platform) {
1299
+ // An edge deployment whose only ingress is this method declares its requests runtime-framed at
1300
+ // construction; everything else stays on the delivered-byte check (see `trustBodyFraming`).
1301
+ if (this.trustBodyFraming)
1302
+ markTrustedBodyFraming(req);
1246
1303
  // A real `Request` satisfies `RequestSource`, so it's passed straight through - no per-request
1247
1304
  // wrapper allocation on the Web/Bun hot path.
1248
1305
  return this.fetchSource(req, platform);
@@ -1365,11 +1422,15 @@ export class Server {
1365
1422
  // header or body hook reads them through its view), and on the no-hook path too (which returns
1366
1423
  // the outcome straight to the writer without the finish step below).
1367
1424
  const statics = this.staticResponseHeaders;
1425
+ // The fold also publishes the all-lowercase proof, which is why it is worth doing here rather
1426
+ // than in the finish step: it is the only stage the HOOKLESS lane passes through, and that lane
1427
+ // returns straight to the writer below without ever reaching `finishNodeResponse`.
1428
+ const markLowercase = !this.hasRawNodeResponseHook;
1368
1429
  const outcome = statics === undefined
1369
1430
  ? resolved
1370
1431
  : resolved instanceof Promise
1371
- ? resolved.then((settled) => withStaticNodeHeaders(settled, statics))
1372
- : withStaticNodeHeaders(resolved, statics);
1432
+ ? resolved.then((settled) => withStaticNodeHeaders(settled, statics, markLowercase))
1433
+ : withStaticNodeHeaders(resolved, statics, markLowercase);
1373
1434
  if (webResponseHooks) {
1374
1435
  return outcome instanceof Promise
1375
1436
  ? outcome.then((settled) => this.finishNodeWebResponse(settled, source, runtime))
@@ -1410,12 +1471,22 @@ export class Server {
1410
1471
  : runtime.fromResponse(transformed);
1411
1472
  }
1412
1473
  let headers = outcome.headers;
1474
+ // ONE pass over the names, here, before any twin runs. The same question is asked three times per
1475
+ // request downstream - the Content-Type lookup just below, the header view's alias index, and the
1476
+ // `@nifrajs/node` writer's normalization gate - and each used to walk the record itself. An app
1477
+ // declaring static headers has already had it answered by the fold in `resolveNode`, for free, so
1478
+ // the pass runs only for the records nothing looked at yet.
1479
+ const lowercaseKeys = headers === undefined ||
1480
+ hasLowercaseHeaderKeysMark(headers) ||
1481
+ headerKeysAllLowercase(headers);
1413
1482
  if (outcome.kind === "json" && outcome.body !== null) {
1414
1483
  // The json render adds its Content-Type at WRITE time, so a body hook checking content types
1415
1484
  // would see nothing. Materialize the writer's own value into the hook-visible record - same
1416
1485
  // string the writer would emit, so the wire is unchanged.
1417
1486
  const hasType = headers !== undefined &&
1418
- Object.keys(headers).some((key) => key.toLowerCase() === "content-type");
1487
+ (lowercaseKeys
1488
+ ? headers["content-type"] !== undefined
1489
+ : Object.keys(headers).some((key) => key.toLowerCase() === "content-type"));
1419
1490
  if (!hasType) {
1420
1491
  const defaultContentType = runtime.jsonContentType ?? "application/json;charset=utf-8";
1421
1492
  if (headers === undefined) {
@@ -1430,6 +1501,13 @@ export class Server {
1430
1501
  }
1431
1502
  }
1432
1503
  }
1504
+ // Publish the proof for the view and the direct writer - but only for an app whose every twin
1505
+ // normalizes case. A raw `onNodeResponse` twin writes the record straight, past the view, so a
1506
+ // mark set before it ran could be made to lie, and a lying mark would ship a mixed-case name that
1507
+ // `Headers` lowercases on every other runtime. Those apps keep the per-reader scans.
1508
+ if (lowercaseKeys && headers !== undefined && !this.hasRawNodeResponseHook) {
1509
+ markLowercaseHeaderKeys(headers);
1510
+ }
1433
1511
  const context = {
1434
1512
  status: outcome.status,
1435
1513
  headers,
@@ -1551,13 +1629,27 @@ export class Server {
1551
1629
  return { kind: "reject", response: jsonError(403, "forbidden_origin") };
1552
1630
  }
1553
1631
  if (handler.upgrade === undefined) {
1554
- return { kind: "upgrade", handler, data: undefined, pubsub, attach };
1632
+ return {
1633
+ kind: "upgrade",
1634
+ handler,
1635
+ data: undefined,
1636
+ pubsub,
1637
+ attach,
1638
+ maxPayloadBytes: this.wsMaxPayloadBytes,
1639
+ };
1555
1640
  }
1556
1641
  const upgradeSignal = getNeverAbortSignal();
1557
- const ctx = new RequestContext(req, params, url.search, upgradeSignal, createUnboundedRequestBudget(upgradeSignal), platform, this.maxBodyBytes);
1642
+ const ctx = new RequestContext(req, params, url.search, upgradeSignal, createUnboundedRequestBudget(upgradeSignal), platform, this.maxBodyBytes, this.protoPoisoning);
1558
1643
  const settle = (value) => value instanceof Response
1559
1644
  ? { kind: "reject", response: value }
1560
- : { kind: "upgrade", handler, data: value, pubsub, attach };
1645
+ : {
1646
+ kind: "upgrade",
1647
+ handler,
1648
+ data: value,
1649
+ pubsub,
1650
+ attach,
1651
+ maxPayloadBytes: this.wsMaxPayloadBytes,
1652
+ };
1561
1653
  try {
1562
1654
  const result = handler.upgrade(ctx);
1563
1655
  return result instanceof Promise
@@ -1816,6 +1908,12 @@ export class Server {
1816
1908
  }
1817
1909
  const match = this.catalog.find(source.method, pathname);
1818
1910
  if (!match.found) {
1911
+ const mounted = this.fetchMount(pathname, source, platform);
1912
+ if (mounted !== undefined) {
1913
+ return mounted instanceof Promise
1914
+ ? mounted.then((response) => wrapResponse(response))
1915
+ : wrapResponse(mounted);
1916
+ }
1819
1917
  if (match.reason === "method-not-allowed") {
1820
1918
  return wrapResponse(jsonError(405, "method_not_allowed", { Allow: match.allowed.join(", ") }));
1821
1919
  }
@@ -1829,14 +1927,38 @@ export class Server {
1829
1927
  }
1830
1928
  return this.runMatched(source, platform, match.payload, params, search, finalize, wrapResponse, onTimeout, webFast);
1831
1929
  }
1930
+ fetchMount(pathname, source, platform) {
1931
+ for (const mount of this.fetchMounts) {
1932
+ if (!underMountPrefix(pathname, mount.path))
1933
+ continue;
1934
+ const request = requestOf(source);
1935
+ return mount.handler(mount.stripPrefix ? stripMountPrefix(request, mount.path) : request, platform);
1936
+ }
1937
+ return undefined;
1938
+ }
1832
1939
  /** Run a route that has already been matched by the runtime or Nifra's portable router. */
1833
1940
  runMatched(source, platform, entry, params, search, finalize, wrapResponse, onTimeout, webFast) {
1941
+ // The route's transport byte cap: one property write here; the capped shadowing of direct
1942
+ // `c.req` body readers happens lazily at `c.req` access (`applyTransportCap`), so a route that
1943
+ // never direct-reads pays nothing. Framework readers bypass the shadow (`rawBodySourceOf`) and
1944
+ // keep their own caps, so `c.boundedBody(explicit)` still overrides upward.
1945
+ // `bodyLimit: "unlimited"` (undefined here) skips the cap entirely.
1946
+ // A schema route consumes and validates the body through `readBodyInput` before any derive or
1947
+ // handler can reach `c.req`; that lane already enforces `entry.bodyLimit`. Installing the full
1948
+ // direct-reader cap on `c.req` here would allocate bound readers, closures, and a stream wrapper
1949
+ // for a body that is already consumed. Keep the lazy transport cap for raw-body routes, where a
1950
+ // user read is the only framework-owned body boundary.
1951
+ if (entry.bodyLimit !== undefined && entry.schema?.body === undefined) {
1952
+ const method = source.method;
1953
+ if (method !== "GET" && method !== "HEAD")
1954
+ markTransportCap(source, entry.bodyLimit);
1955
+ }
1834
1956
  // An idempotency route runs its dedupe lane first; on a fresh key it delegates to the normal lanes
1835
1957
  // (with the body buffered). All non-idempotent routes skip straight to the lanes - no added cost.
1836
1958
  // The runtime is always present when a route resolved idempotency (enforced at registration).
1837
1959
  if (entry.idempotent !== undefined && this.idempotencyRuntime !== undefined) {
1838
1960
  return this.idempotencyRuntime.run(entry.idempotent, requestOf(source), platform, entry, params, search, wrapResponse, {
1839
- maxBodyBytes: this.maxBodyBytes,
1961
+ maxBodyBytes: entry.bodyLimit ?? this.maxBodyBytes,
1840
1962
  runLanes: (buffered, plat, ent, prm, srch) => this.idempotencyRunLanes(buffered, plat, ent, prm, srch),
1841
1963
  });
1842
1964
  }
@@ -1900,6 +2022,8 @@ export class Server {
1900
2022
  }
1901
2023
  /** The narrowest bare route: a syntactic `() => ...` handler cannot observe the context argument, so
1902
2024
  * successful requests can skip allocating `RequestContext`. Errors still allocate one for logging. */
2025
+ // @ts-expect-error TS6133 -- invoked structurally by compiled plans (internal/route-execution.ts)
2026
+ // biome-ignore lint/correctness/noUnusedPrivateClassMembers: invoked structurally by compiled plans (internal/route-execution.ts)
1903
2027
  runContextlessBare(entry, source, params, search, signal, budget, platform, finalize, wrapResponse) {
1904
2028
  let result;
1905
2029
  try {
@@ -1916,7 +2040,7 @@ export class Server {
1916
2040
  contextlessBareError(err, source, params, search, signal, budget, platform, wrapResponse) {
1917
2041
  if (err instanceof Response)
1918
2042
  return wrapResponse(err);
1919
- const ctx = new RequestContext(source, params, search, signal, budget, platform, this.maxBodyBytes);
2043
+ const ctx = new RequestContext(source, params, search, signal, budget, platform, this.maxBodyBytes, this.protoPoisoning);
1920
2044
  this.logRequestError(err, ctx);
1921
2045
  return wrapResponse(jsonError(500, "internal_error"));
1922
2046
  }
@@ -1928,6 +2052,8 @@ export class Server {
1928
2052
  * (a thrown `Response` is control flow; anything else is a logged flat 500). This is where nifra skips
1929
2053
  * the per-request async-frame tax - the same win codegen routers get, but without `eval`.
1930
2054
  */
2055
+ // @ts-expect-error TS6133 -- invoked structurally by compiled plans (internal/route-execution.ts)
2056
+ // biome-ignore lint/correctness/noUnusedPrivateClassMembers: invoked structurally by compiled plans (internal/route-execution.ts)
1931
2057
  runBare(entry, ctx, finalize, wrapResponse) {
1932
2058
  let result;
1933
2059
  try {
@@ -1953,7 +2079,7 @@ export class Server {
1953
2079
  * decoration order, same error routing (thrown `Response` = control flow; anything else logs and
1954
2080
  * 500s), same respond semantics (the lifecycle parity suite pins it).
1955
2081
  */
1956
- buildFusedWeb(handler, decorations, contextless) {
2082
+ buildFusedWeb(handler, decorations, contextless, maxBodyBytes) {
1957
2083
  // The fused lanes return to the runtime directly, so the framework's own error renders fold in
1958
2084
  // static headers here rather than through the shared `wrapResponse` seam.
1959
2085
  const logError = (err, ctx) => {
@@ -1973,21 +2099,21 @@ export class Server {
1973
2099
  }
1974
2100
  catch (err) {
1975
2101
  return logError(err, nativeContext
1976
- ? RequestContext.native(source, params, search, this.maxBodyBytes, platform)
1977
- : new RequestContext(source, params, search, signal, budget, platform, this.maxBodyBytes));
2102
+ ? RequestContext.native(source, params, search, maxBodyBytes, platform, this.protoPoisoning)
2103
+ : new RequestContext(source, params, search, signal, budget, platform, maxBodyBytes, this.protoPoisoning));
1978
2104
  }
1979
2105
  if (result instanceof Promise) {
1980
- return result.then((value) => fusedRespondNoSet(value, this.responseBodyTag, this.staticResponseHeaders), (err) => logError(err, nativeContext
1981
- ? RequestContext.native(source, params, search, this.maxBodyBytes, platform)
1982
- : new RequestContext(source, params, search, signal, budget, platform, this.maxBodyBytes)));
2106
+ return result.then((value) => fusedRespondNoSet(value, this.responseBodyTag, this.staticResponseHeaders, this.onResponseHooks.length === 0), (err) => logError(err, nativeContext
2107
+ ? RequestContext.native(source, params, search, maxBodyBytes, platform, this.protoPoisoning)
2108
+ : new RequestContext(source, params, search, signal, budget, platform, maxBodyBytes, this.protoPoisoning)));
1983
2109
  }
1984
- return fusedRespondNoSet(result, this.responseBodyTag, this.staticResponseHeaders);
2110
+ return fusedRespondNoSet(result, this.responseBodyTag, this.staticResponseHeaders, this.onResponseHooks.length === 0);
1985
2111
  };
1986
2112
  }
1987
2113
  return (source, params, search, signal, budget, platform, nativeContext) => {
1988
2114
  const ctx = nativeContext
1989
- ? RequestContext.native(source, params, search, this.maxBodyBytes, platform)
1990
- : new RequestContext(source, params, search, signal, budget, platform, this.maxBodyBytes);
2115
+ ? RequestContext.native(source, params, search, maxBodyBytes, platform, this.protoPoisoning)
2116
+ : new RequestContext(source, params, search, signal, budget, platform, maxBodyBytes, this.protoPoisoning);
1991
2117
  if (decorations !== undefined)
1992
2118
  Object.assign(ctx, decorations);
1993
2119
  let result;
@@ -1998,15 +2124,15 @@ export class Server {
1998
2124
  return logError(err, ctx);
1999
2125
  }
2000
2126
  if (result instanceof Promise) {
2001
- return result.then((value) => fusedRespond(value, ctx, this.responseBodyTag, this.staticResponseHeaders), (err) => logError(err, ctx));
2127
+ return result.then((value) => fusedRespond(value, ctx, this.responseBodyTag, this.staticResponseHeaders, this.onResponseHooks.length === 0), (err) => logError(err, ctx));
2002
2128
  }
2003
- return fusedRespond(result, ctx, this.responseBodyTag, this.staticResponseHeaders);
2129
+ return fusedRespond(result, ctx, this.responseBodyTag, this.staticResponseHeaders, this.onResponseHooks.length === 0);
2004
2130
  };
2005
2131
  }
2006
2132
  /** Compile the eligible body-only route's parser → validator → handler continuation once. The
2007
2133
  * bounded parser remains shared with the generic lane; only the route-invariant entry lookups and
2008
2134
  * lifecycle dispatch disappear from the common synchronous-validator/synchronous-handler case. */
2009
- buildFusedBodyRunner(handler, bodySchema, decorations) {
2135
+ buildFusedBodyRunner(handler, bodySchema, decorations, maxBodyBytes) {
2010
2136
  const logError = (err, ctx, wrapResponse) => this.bareError(err, ctx, wrapResponse);
2011
2137
  const runHandler = (ctx, finalize, wrapResponse) => {
2012
2138
  if (decorations !== undefined)
@@ -2068,16 +2194,16 @@ export class Server {
2068
2194
  };
2069
2195
  return (source, params, search, signal, budget, platform, nativeContext, finalize, wrapResponse) => {
2070
2196
  const ctx = nativeContext
2071
- ? RequestContext.native(source, params, search, this.maxBodyBytes, platform)
2072
- : new RequestContext(source, params, search, signal, budget, platform, this.maxBodyBytes);
2197
+ ? RequestContext.native(source, params, search, maxBodyBytes, platform, this.protoPoisoning)
2198
+ : new RequestContext(source, params, search, signal, budget, platform, maxBodyBytes, this.protoPoisoning);
2073
2199
  const finish = (value) => runParsed(value, ctx, finalize, wrapResponse);
2074
- return this.readBodyInput(source, finish, wrapResponse, (err) => logError(err, ctx, wrapResponse));
2200
+ return this.readBodyInput(source, maxBodyBytes, finish, wrapResponse, (err) => logError(err, ctx, wrapResponse));
2075
2201
  };
2076
2202
  }
2077
2203
  /** Web adapter wrapper for the shared body runner. Node passes its native finalizer directly through
2078
2204
  * the execution plan, while Web needs the live context to preserve lazy `c.set` controls. */
2079
2205
  buildFusedBodyWeb(body) {
2080
- return (source, params, search, signal, budget, platform, nativeContext) => body(source, params, search, signal, budget, platform, nativeContext, (result, _set, ctx) => fusedRespond(result, ctx, this.responseBodyTag, this.staticResponseHeaders), this.wrapWebResponse);
2206
+ return (source, params, search, signal, budget, platform, nativeContext) => body(source, params, search, signal, budget, platform, nativeContext, (result, _set, ctx) => fusedRespond(result, ctx, this.responseBodyTag, this.staticResponseHeaders, this.onResponseHooks.length === 0), this.wrapWebResponse);
2081
2207
  }
2082
2208
  /** The fused Web lane for a route whose only lifecycle step is a query schema: parse + validate +
2083
2209
  * handler + respond in one closure, no lifecycle promise when the validator and handler are sync.
@@ -2086,7 +2212,7 @@ export class Server {
2086
2212
  * `validationError(issues)` (recovery hooks disqualify the route from this lane), a thrown
2087
2213
  * `Response` passes through, anything else logs and returns a flat 500, and an async validator
2088
2214
  * falls to a then-chain with the same steps. */
2089
- buildFusedQueryWeb(handler, decorations, querySchema) {
2215
+ buildFusedQueryWeb(handler, decorations, querySchema, maxBodyBytes) {
2090
2216
  // The fused lanes return to the runtime directly, so the framework's own error renders fold in
2091
2217
  // static headers here rather than through the shared `wrapResponse` seam.
2092
2218
  const logError = (err, ctx) => {
@@ -2105,14 +2231,14 @@ export class Server {
2105
2231
  return logError(err, ctx);
2106
2232
  }
2107
2233
  if (result instanceof Promise) {
2108
- return result.then((settled) => fusedRespond(settled, ctx, this.responseBodyTag, this.staticResponseHeaders), (err) => logError(err, ctx));
2234
+ return result.then((settled) => fusedRespond(settled, ctx, this.responseBodyTag, this.staticResponseHeaders, this.onResponseHooks.length === 0), (err) => logError(err, ctx));
2109
2235
  }
2110
- return fusedRespond(result, ctx, this.responseBodyTag, this.staticResponseHeaders);
2236
+ return fusedRespond(result, ctx, this.responseBodyTag, this.staticResponseHeaders, this.onResponseHooks.length === 0);
2111
2237
  };
2112
2238
  return (source, params, search, signal, budget, platform, nativeContext) => {
2113
2239
  const ctx = nativeContext
2114
- ? RequestContext.native(source, params, search, this.maxBodyBytes, platform)
2115
- : new RequestContext(source, params, search, signal, budget, platform, this.maxBodyBytes);
2240
+ ? RequestContext.native(source, params, search, maxBodyBytes, platform, this.protoPoisoning)
2241
+ : new RequestContext(source, params, search, signal, budget, platform, maxBodyBytes, this.protoPoisoning);
2116
2242
  if (decorations !== undefined)
2117
2243
  Object.assign(ctx, decorations);
2118
2244
  let validation;
@@ -2138,6 +2264,8 @@ export class Server {
2138
2264
  this.logRequestError(err, ctx);
2139
2265
  return wrapResponse(jsonError(500, "internal_error"));
2140
2266
  }
2267
+ // @ts-expect-error TS6133 -- invoked structurally by compiled plans (internal/route-execution.ts)
2268
+ // biome-ignore lint/correctness/noUnusedPrivateClassMembers: invoked structurally by compiled plans (internal/route-execution.ts)
2141
2269
  runWithAround(entry, ctx, run, finalize, wrapResponse) {
2142
2270
  let outcome;
2143
2271
  try {
@@ -2165,36 +2293,26 @@ export class Server {
2165
2293
  };
2166
2294
  return dispatch(0);
2167
2295
  }
2296
+ // @ts-expect-error TS6133 -- invoked structurally by compiled plans (internal/route-execution.ts)
2297
+ // biome-ignore lint/correctness/noUnusedPrivateClassMembers: invoked structurally by compiled plans (internal/route-execution.ts)
2168
2298
  runBodyOnly(entry, source, ctx, finalize, wrapResponse) {
2169
- return this.readBodyInput(source, (parsed) => this.finishBodyOnly(entry, parsed, ctx, finalize, wrapResponse), wrapResponse, (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse));
2299
+ return this.readBodyInput(source, entry.bodyLimit ?? this.maxBodyBytes, (parsed) => this.finishBodyOnly(entry, parsed, ctx, finalize, wrapResponse), wrapResponse, (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse));
2170
2300
  }
2171
2301
  /** Shared bounded body framing/parser. Both body lanes use the same trust-boundary checks; only
2172
2302
  * the validation + handler continuation differs. */
2173
- readBodyInput(source, onParsed, wrapResponse, onError) {
2303
+ readBodyInput(source, maxBodyBytes, onParsed, wrapResponse, onError) {
2174
2304
  const contentType = headerOf(source, "content-type") ?? "";
2175
2305
  if (contentType !== "application/json" && !contentType.includes("application/json")) {
2176
2306
  if (isUrlEncodedForm(contentType)) {
2177
- return readBoundedForm(source, this.maxBodyBytes).then((form) => (form instanceof Response ? wrapResponse(form) : onParsed(form)), onError);
2307
+ return readBoundedForm(source, maxBodyBytes).then((form) => (form instanceof Response ? wrapResponse(form) : onParsed(form)), onError);
2178
2308
  }
2179
2309
  return Promise.resolve(wrapResponse(jsonError(415, "unsupported_media_type")));
2180
2310
  }
2181
- // A framed, in-cap, non-chunked body can use native json() directly. Chunked or length-less
2182
- // bodies use readBoundedJson, which enforces the streaming cap before parsing.
2183
- const declared = headerOf(source, "content-length");
2184
- if (declared !== null) {
2185
- const length = parseContentLength(declared);
2186
- if (length === undefined) {
2187
- return Promise.resolve(wrapResponse(jsonError(400, "invalid_content_length")));
2188
- }
2189
- if (length > this.maxBodyBytes) {
2190
- return Promise.resolve(wrapResponse(jsonError(413, "payload_too_large")));
2191
- }
2192
- if (headerOf(source, "transfer-encoding") === null) {
2193
- return source.json().then(onParsed, () => wrapResponse(jsonError(400, "invalid_json")));
2194
- }
2195
- }
2311
+ // All JSON bodies go through readBoundedJson - the single enforcement point for the framed
2312
+ // fast path (post-read byte re-check), the streaming cap, and the prototype-poisoning guard.
2313
+ // A separate inlined native-json() fast path here would fork the trust boundary in two.
2196
2314
  try {
2197
- return this.readBoundedJson(source).then((parsed) => (parsed instanceof Response ? wrapResponse(parsed) : onParsed(parsed)), onError);
2315
+ return this.readBoundedJson(source, maxBodyBytes, (parsed) => (parsed instanceof Response ? wrapResponse(parsed) : onParsed(parsed)), onError);
2198
2316
  }
2199
2317
  catch (err) {
2200
2318
  return Promise.resolve(onError(err));
@@ -2296,6 +2414,8 @@ export class Server {
2296
2414
  ctx.body = result.value;
2297
2415
  return this.executeHandler(entry, ctx, finalize);
2298
2416
  }
2417
+ // @ts-expect-error TS6133 -- invoked structurally by compiled plans (internal/route-execution.ts)
2418
+ // biome-ignore lint/correctness/noUnusedPrivateClassMembers: invoked structurally by compiled plans (internal/route-execution.ts)
2299
2419
  runQueryOnly(entry, ctx, finalize, wrapResponse) {
2300
2420
  try {
2301
2421
  // Call the validator directly for the raw StandardResult (read `.issues`/`.value`) - skip
@@ -2446,8 +2566,18 @@ export class Server {
2446
2566
  * node-direct primitives); an early/error `Response` (validation, thrown, 500) through `wrapResponse`.
2447
2567
  */
2448
2568
  /** Synchronous until a validator, lifecycle hook, handler, or contract check actually returns a Promise. */
2569
+ // @ts-expect-error TS6133 -- invoked structurally by compiled plans (internal/route-execution.ts)
2570
+ // biome-ignore lint/correctness/noUnusedPrivateClassMembers: invoked structurally by compiled plans (internal/route-execution.ts)
2449
2571
  runLifecycle(entry, source, ctx, finalize, wrapResponse) {
2450
2572
  try {
2573
+ const headersSchema = entry.schema?.headers;
2574
+ if (headersSchema !== undefined) {
2575
+ const validation = headersSchema["~standard"].validate(headerObjectOf(source.headers));
2576
+ if (validation instanceof Promise) {
2577
+ return validation.then((result) => this.runLifecycleAfterHeadersResult(entry, source, ctx, finalize, wrapResponse, result), (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse));
2578
+ }
2579
+ return this.runLifecycleAfterHeadersResult(entry, source, ctx, finalize, wrapResponse, validation);
2580
+ }
2451
2581
  const paramsSchema = entry.schema?.params;
2452
2582
  if (paramsSchema !== undefined) {
2453
2583
  const validation = paramsSchema["~standard"].validate(ctx.params);
@@ -2469,6 +2599,35 @@ export class Server {
2469
2599
  return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse);
2470
2600
  }
2471
2601
  }
2602
+ runLifecycleAfterHeadersResult(entry, source, ctx, finalize, wrapResponse, result) {
2603
+ try {
2604
+ const headersError = this.applyLifecycleValidation(entry, result, ctx, "headers");
2605
+ if (headersError instanceof Promise) {
2606
+ return headersError.then((error) => error === undefined
2607
+ ? this.runLifecycleAfterHeaders(entry, source, ctx, finalize, wrapResponse)
2608
+ : wrapResponse(error), (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse));
2609
+ }
2610
+ return headersError === undefined
2611
+ ? this.runLifecycleAfterHeaders(entry, source, ctx, finalize, wrapResponse)
2612
+ : wrapResponse(headersError);
2613
+ }
2614
+ catch (err) {
2615
+ return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse);
2616
+ }
2617
+ }
2618
+ runLifecycleAfterHeaders(entry, source, ctx, finalize, wrapResponse) {
2619
+ const paramsSchema = entry.schema?.params;
2620
+ if (paramsSchema === undefined) {
2621
+ if (entry.schema?.body === undefined)
2622
+ return this.runQueryAndLifecycle(entry, ctx, finalize, wrapResponse);
2623
+ return this.runLifecycleAfterParams(entry, source, ctx, finalize, wrapResponse, undefined);
2624
+ }
2625
+ const validation = paramsSchema["~standard"].validate(ctx.params);
2626
+ if (validation instanceof Promise) {
2627
+ return validation.then((result) => this.runLifecycleAfterParamsResult(entry, source, ctx, finalize, wrapResponse, result), (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse));
2628
+ }
2629
+ return this.runLifecycleAfterParamsResult(entry, source, ctx, finalize, wrapResponse, validation);
2630
+ }
2472
2631
  /** Registration-specialized query lifecycle: query validation (including recovery) → hooks. */
2473
2632
  runLifecycleQuery(entry, ctx, finalize, wrapResponse) {
2474
2633
  try {
@@ -2492,12 +2651,16 @@ export class Server {
2492
2651
  }
2493
2652
  }
2494
2653
  /** Registration-specialized body lifecycle: bounded body validation (including recovery) → hooks. */
2654
+ // @ts-expect-error TS6133 -- invoked structurally by compiled plans (internal/route-execution.ts)
2655
+ // biome-ignore lint/correctness/noUnusedPrivateClassMembers: invoked structurally by compiled plans (internal/route-execution.ts)
2495
2656
  runLifecycleBody(entry, source, ctx, finalize, wrapResponse) {
2496
2657
  return this.readAndValidateBody(source, entry, ctx).then((bodyError) => bodyError === undefined
2497
2658
  ? this.runLifecycleHooks(entry, ctx, finalize, wrapResponse)
2498
2659
  : wrapResponse(bodyError), (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse));
2499
2660
  }
2500
2661
  /** Registration-specialized body + query lifecycle: bounded body validation → query validation → hooks. */
2662
+ // @ts-expect-error TS6133 -- invoked structurally by compiled plans (internal/route-execution.ts)
2663
+ // biome-ignore lint/correctness/noUnusedPrivateClassMembers: invoked structurally by compiled plans (internal/route-execution.ts)
2501
2664
  runLifecycleBodyQuery(entry, source, ctx, finalize, wrapResponse) {
2502
2665
  return this.readAndValidateBody(source, entry, ctx).then((bodyError) => bodyError === undefined
2503
2666
  ? this.runLifecycleQuery(entry, ctx, finalize, wrapResponse)
@@ -2652,8 +2815,12 @@ export class Server {
2652
2815
  }
2653
2816
  async runLifecycleHooksAsync(entry, ctx, finalize, wrapResponse) {
2654
2817
  try {
2655
- for (const hook of entry.beforeHandle) {
2656
- const outcome = hook(ctx);
2818
+ // Indexed, not `for...of`. An array iterator declared inside an async function stays live
2819
+ // across the `await` below, so neither engine can sink it - unlike the sync lanes above,
2820
+ // where V8 elides it. Measured cost of the iterator form: 43ns per hook on Bun, ~9ns on
2821
+ // Node, on a chain whose hooks return synchronously (the common shape).
2822
+ for (let i = 0; i < entry.beforeHandle.length; i++) {
2823
+ const outcome = entry.beforeHandle[i](ctx);
2657
2824
  const early = outcome instanceof Promise ? await outcome : outcome;
2658
2825
  if (early !== undefined)
2659
2826
  return finalize(early, responseSet(ctx));
@@ -2761,8 +2928,10 @@ export class Server {
2761
2928
  if (err instanceof Response)
2762
2929
  return wrapResponse(err);
2763
2930
  // onError hooks may return a custom response; otherwise the default 500 stands.
2764
- for (const hook of entry.onError) {
2765
- const outcome = hook(err, ctx);
2931
+ // Indexed for the same reason as the `beforeHandle` chain above: the iterator would outlive
2932
+ // the `await` and stay heap-allocated on both engines.
2933
+ for (let i = 0; i < entry.onError.length; i++) {
2934
+ const outcome = entry.onError[i](err, ctx);
2766
2935
  const handled = outcome instanceof Promise ? await outcome : outcome;
2767
2936
  if (handled !== undefined)
2768
2937
  return finalize(handled, responseSet(ctx));
@@ -2775,6 +2944,11 @@ export class Server {
2775
2944
  /** Log an unhandled request error to the (redacting) logger - shared by {@link runLifecycle} and the
2776
2945
  * bare fast path ({@link bareError}) so both record the same fields. Never throws; never leaks. */
2777
2946
  logRequestError(err, ctx) {
2947
+ // An error's own text can quote the input that produced it, so how much of it reaches the sink is
2948
+ // the app's call (`errorLogDetail`). The default keeps it: a 500 with no message and no stack is
2949
+ // an incident nobody can diagnose, and the framework already ships the narrower instrument for
2950
+ // the leak - a redacting logger with `valuePatterns`. `"none"` is there for an untrusted sink.
2951
+ const detail = this.errorLogDetail;
2778
2952
  this.logger.error("unhandled request error", {
2779
2953
  method: ctx.req.method,
2780
2954
  path: pathnameOf(ctx.req.url),
@@ -2782,21 +2956,21 @@ export class Server {
2782
2956
  // `detail`, not `message`: the logger uses `message` for its own first argument, so a field of
2783
2957
  // that name is silently overwritten and the thrown error's own text never reaches the sink. It
2784
2958
  // survived only incidentally inside `stack`, and was lost outright for a non-Error throw.
2785
- detail: err instanceof Error ? err.message : String(err),
2786
- stack: err instanceof Error ? err.stack : undefined,
2959
+ ...(detail === "none" ? {} : { detail: err instanceof Error ? err.message : String(err) }),
2960
+ ...(detail === "full" && err instanceof Error ? { stack: err.stack } : {}),
2787
2961
  });
2788
2962
  }
2789
2963
  async readAndValidateBody(req, entry, ctx) {
2790
2964
  const contentType = headerOf(req, "content-type") ?? "";
2791
2965
  let parsed;
2792
2966
  if (contentType === "application/json" || contentType.includes("application/json")) {
2793
- const json = await this.readBoundedJson(req);
2967
+ const json = await this.readBoundedJson(req, entry.bodyLimit ?? this.maxBodyBytes);
2794
2968
  if (json instanceof Response)
2795
2969
  return json;
2796
2970
  parsed = json;
2797
2971
  }
2798
2972
  else if (isUrlEncodedForm(contentType)) {
2799
- const form = await readBoundedForm(req, this.maxBodyBytes);
2973
+ const form = await readBoundedForm(req, entry.bodyLimit ?? this.maxBodyBytes);
2800
2974
  if (form instanceof Response)
2801
2975
  return form;
2802
2976
  parsed = form;
@@ -2818,6 +2992,8 @@ export class Server {
2818
2992
  ctx.body = value;
2819
2993
  else if (kind === "query")
2820
2994
  ctx.query = value;
2995
+ else if (kind === "headers")
2996
+ ctx.headers = value;
2821
2997
  else
2822
2998
  ctx.params = value;
2823
2999
  };
@@ -2843,7 +3019,9 @@ export class Server {
2843
3019
  ? entry.schema?.body
2844
3020
  : kind === "query"
2845
3021
  ? entry.schema?.query
2846
- : entry.schema?.params;
3022
+ : kind === "params"
3023
+ ? entry.schema?.params
3024
+ : entry.schema?.headers;
2847
3025
  const retried = schema["~standard"].validate(recovery);
2848
3026
  if (retried instanceof Promise) {
2849
3027
  return retried.then((settled) => {
@@ -2858,42 +3036,59 @@ export class Server {
2858
3036
  assign(retried.value);
2859
3037
  return undefined;
2860
3038
  }
2861
- /**
2862
- * Read the body as text, capped at `maxBodyBytes`. Rejects (`null`) on a
2863
- * `Content-Length` over the cap *before* buffering, and aborts mid-stream once the
2864
- * running byte count exceeds it - so a lying or absent length can't force us to
2865
- * buffer an oversized payload.
2866
- *
2867
- * Fast path: when a non-chunked request carries a `Content-Length` within the cap,
2868
- * a native `req.json()` is already bounded - under HTTP/1.1 + HTTP/2 framing the
2869
- * runtime delivers at most `Content-Length` bytes - so we skip the manual stream
2870
- * loop and a separate text decode. It trusts the wire
2871
- * *framing*, not the header value: nifra only ever receives framed Requests from the
2872
- * runtime's HTTP server, never a hand-built one with a mismatched length. Chunked or
2873
- * length-less bodies fall through to the streaming byte-cap guard below.
2874
- */
2875
- async readBoundedJson(req) {
2876
- return readBoundedJsonSource(req, this.maxBodyBytes);
3039
+ readBoundedJson(req, maxBodyBytes = this.maxBodyBytes, onResult, onError) {
3040
+ // Not `async`: this is a pass-through, and wrapping the lane's own promise in a coroutine
3041
+ // frame costs an extra microtask hop on every JSON body.
3042
+ return onResult === undefined
3043
+ ? readBoundedJsonSource(req, maxBodyBytes, this.protoPoisoning)
3044
+ : readBoundedJsonSource(req, maxBodyBytes, this.protoPoisoning, onResult, onError);
2877
3045
  }
2878
3046
  /** Adapt one route's compiled execution plan to Bun's already-matched request shape. Route
2879
3047
  * semantics remain in the plan; this closure only supplies native params and deadline fallback. */
2880
3048
  compileBunNativeHandler(entry, paramNames, fused, signal, budget) {
3049
+ // This callback is reached only from Bun's compiled native route table. Bun has already parsed
3050
+ // the HTTP framing, so the JSON lane may retain its native fused `json()` parse without weakening
3051
+ // the portable/adapted source contract (those sources are never marked).
3052
+ const markFramed = (request) => markTrustedBodyFraming(request);
3053
+ // The fused native lane bypasses `runMatched`, so the route's transport byte cap must be
3054
+ // marked here too - otherwise a Bun `listen()` fused route would leave direct `c.req` body
3055
+ // reads uncapped. The non-fused branches go through `fetchMatched` -> `runMatched`, which marks.
3056
+ const bodyLimit = entry.schema?.body === undefined ? entry.bodyLimit : undefined;
3057
+ const inner = fused;
3058
+ const capped = inner === undefined || bodyLimit === undefined
3059
+ ? inner
3060
+ : (source, params, search, fusedSignal, fusedBudget, platform, nativeContext) => {
3061
+ const method = source.method;
3062
+ if (method !== "GET" && method !== "HEAD")
3063
+ markTransportCap(source, bodyLimit);
3064
+ return inner(source, params, search, fusedSignal, fusedBudget, platform, nativeContext);
3065
+ };
2881
3066
  if (paramNames.length === 0) {
2882
- if (fused === undefined) {
2883
- return (request) => this.fetchMatched(request, entry, EMPTY_PARAMS);
3067
+ if (capped === undefined) {
3068
+ return (request) => {
3069
+ markFramed(request);
3070
+ return this.fetchMatched(request, entry, EMPTY_PARAMS);
3071
+ };
2884
3072
  }
2885
3073
  if (this.acceptInboundDeadlines) {
2886
- return (request) => request.headers.get(NIFRA_DEADLINE_HEADER) !== null
2887
- ? this.fetchMatched(request, entry, EMPTY_PARAMS)
2888
- : fused(request, EMPTY_PARAMS, undefined, signal, budget, undefined, true);
3074
+ return (request) => {
3075
+ markFramed(request);
3076
+ return request.headers.get(NIFRA_DEADLINE_HEADER) !== null
3077
+ ? this.fetchMatched(request, entry, EMPTY_PARAMS)
3078
+ : capped(request, EMPTY_PARAMS, undefined, signal, budget, undefined, true);
3079
+ };
2889
3080
  }
2890
- return (request) => fused(request, EMPTY_PARAMS, undefined, signal, budget, undefined, true);
3081
+ return (request) => {
3082
+ markFramed(request);
3083
+ return capped(request, EMPTY_PARAMS, undefined, signal, budget, undefined, true);
3084
+ };
2891
3085
  }
2892
3086
  const malformed = paramNames.length === 1
2893
3087
  ? (params) => params[paramNames[0]]?.includes("\uFFFD") === true
2894
3088
  : hasReplacementParam;
2895
- if (fused === undefined) {
3089
+ if (capped === undefined) {
2896
3090
  return (request) => {
3091
+ markFramed(request);
2897
3092
  const params = request.params ?? EMPTY_PARAMS;
2898
3093
  if (malformed(params))
2899
3094
  return this.fetchSource(request);
@@ -2902,19 +3097,21 @@ export class Server {
2902
3097
  }
2903
3098
  if (this.acceptInboundDeadlines) {
2904
3099
  return (request) => {
3100
+ markFramed(request);
2905
3101
  const params = request.params ?? EMPTY_PARAMS;
2906
3102
  if (malformed(params))
2907
3103
  return this.fetchSource(request);
2908
3104
  return request.headers.get(NIFRA_DEADLINE_HEADER) !== null
2909
3105
  ? this.fetchMatched(request, entry, params)
2910
- : fused(request, params, undefined, signal, budget, undefined, true);
3106
+ : capped(request, params, undefined, signal, budget, undefined, true);
2911
3107
  };
2912
3108
  }
2913
3109
  return (request) => {
3110
+ markFramed(request);
2914
3111
  const params = request.params ?? EMPTY_PARAMS;
2915
3112
  if (malformed(params))
2916
3113
  return this.fetchSource(request);
2917
- return fused(request, params, undefined, signal, budget, undefined, true);
3114
+ return capped(request, params, undefined, signal, budget, undefined, true);
2918
3115
  };
2919
3116
  }
2920
3117
  /** Compile portable route registrations into Bun's native route table. Apps with request-rewrite
@@ -2980,6 +3177,13 @@ export class Server {
2980
3177
  * `"127.0.0.1"` to bind loopback only - an admin surface, a sidecar, or anything that must not be
2981
3178
  * reachable off the box. Omitting it when you meant to restrict is a real exposure, so it is a
2982
3179
  * first-class option rather than something a caller has to drop down to `Bun.serve` for.
3180
+ *
3181
+ * `idleTimeoutSec` is how long Bun tolerates a connection with no bytes moving before it closes
3182
+ * the socket. Bun's default is **10 seconds**, and a request whose handler is still working sends
3183
+ * nothing - so any endpoint slower than 10s (a render, an export, a long upstream call) has its
3184
+ * connection cut mid-flight regardless of `requestTimeoutMs`. Apps with such endpoints must raise
3185
+ * this above their slowest expected response, which is why it is a first-class option and not a
3186
+ * reason to drop down to `Bun.serve`. `0` disables the timeout entirely; max 255.
2983
3187
  */
2984
3188
  listen(port, options) {
2985
3189
  if (typeof Bun === "undefined") {
@@ -3006,12 +3210,15 @@ export class Server {
3006
3210
  // Spread rather than pass `hostname: undefined` - Bun treats an explicit undefined as a value
3007
3211
  // on some option paths, and omitting is what selects its 0.0.0.0 default.
3008
3212
  const bind = options?.hostname === undefined ? {} : { hostname: options.hostname };
3213
+ // Same reasoning as `bind`: omit rather than pass undefined, so Bun's own default applies.
3214
+ const idle = options?.idleTimeoutSec === undefined ? {} : { idleTimeout: options.idleTimeoutSec };
3009
3215
  const nativeRoutes = wsHandlers === undefined ? this.buildBunNativeRoutes() : undefined;
3010
3216
  const running = (wsHandlers === undefined
3011
3217
  ? Bun.serve({
3012
3218
  port,
3013
3219
  reusePort,
3014
3220
  ...bind,
3221
+ ...idle,
3015
3222
  ...(nativeRoutes === undefined ? {} : { routes: nativeRoutes }),
3016
3223
  fetch: (req, server) => this.fetch(req, bunPeerPlatform(server, req)),
3017
3224
  })
@@ -3019,6 +3226,7 @@ export class Server {
3019
3226
  port,
3020
3227
  reusePort,
3021
3228
  ...bind,
3229
+ ...idle,
3022
3230
  fetch: (req, server) => this.bunFetchWithWebSocket(req, server),
3023
3231
  // Bun's `ServerWebSocket<BunWsData>` is runtime-compatible with the handlers' structural
3024
3232
  // `BunSocket` view (kept local so `Bun.*` types never leak into the published .d.ts); the
@@ -3059,6 +3267,14 @@ export class Server {
3059
3267
  await Bun.sleep(DRAIN_POLL_MS);
3060
3268
  }
3061
3269
  server.stop(server.pendingRequests > 0); // force-close iff stragglers remain past the deadline
3270
+ if (this.stopHooks.length === 0)
3271
+ return;
3272
+ const callbacks = this.stopHooks.map((hook) => Promise.resolve().then(hook));
3273
+ const settled = Promise.allSettled(callbacks);
3274
+ await Promise.race([
3275
+ settled,
3276
+ new Promise((resolve) => setTimeout(resolve, STOP_HOOK_TIMEOUT_MS)),
3277
+ ]);
3062
3278
  }
3063
3279
  installSignalHandlers() {
3064
3280
  // Drain, then let the process exit naturally - the stopped server no longer