@nifrajs/core 2.10.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
@@ -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,33 @@ 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
+ if (entry.bodyLimit !== undefined) {
1947
+ const method = source.method;
1948
+ if (method !== "GET" && method !== "HEAD")
1949
+ markTransportCap(source, entry.bodyLimit);
1950
+ }
1834
1951
  // An idempotency route runs its dedupe lane first; on a fresh key it delegates to the normal lanes
1835
1952
  // (with the body buffered). All non-idempotent routes skip straight to the lanes - no added cost.
1836
1953
  // The runtime is always present when a route resolved idempotency (enforced at registration).
1837
1954
  if (entry.idempotent !== undefined && this.idempotencyRuntime !== undefined) {
1838
1955
  return this.idempotencyRuntime.run(entry.idempotent, requestOf(source), platform, entry, params, search, wrapResponse, {
1839
- maxBodyBytes: this.maxBodyBytes,
1956
+ maxBodyBytes: entry.bodyLimit ?? this.maxBodyBytes,
1840
1957
  runLanes: (buffered, plat, ent, prm, srch) => this.idempotencyRunLanes(buffered, plat, ent, prm, srch),
1841
1958
  });
1842
1959
  }
@@ -1900,6 +2017,8 @@ export class Server {
1900
2017
  }
1901
2018
  /** The narrowest bare route: a syntactic `() => ...` handler cannot observe the context argument, so
1902
2019
  * successful requests can skip allocating `RequestContext`. Errors still allocate one for logging. */
2020
+ // @ts-expect-error TS6133 -- invoked structurally by compiled plans (internal/route-execution.ts)
2021
+ // biome-ignore lint/correctness/noUnusedPrivateClassMembers: invoked structurally by compiled plans (internal/route-execution.ts)
1903
2022
  runContextlessBare(entry, source, params, search, signal, budget, platform, finalize, wrapResponse) {
1904
2023
  let result;
1905
2024
  try {
@@ -1916,7 +2035,7 @@ export class Server {
1916
2035
  contextlessBareError(err, source, params, search, signal, budget, platform, wrapResponse) {
1917
2036
  if (err instanceof Response)
1918
2037
  return wrapResponse(err);
1919
- const ctx = new RequestContext(source, params, search, signal, budget, platform, this.maxBodyBytes);
2038
+ const ctx = new RequestContext(source, params, search, signal, budget, platform, this.maxBodyBytes, this.protoPoisoning);
1920
2039
  this.logRequestError(err, ctx);
1921
2040
  return wrapResponse(jsonError(500, "internal_error"));
1922
2041
  }
@@ -1928,6 +2047,8 @@ export class Server {
1928
2047
  * (a thrown `Response` is control flow; anything else is a logged flat 500). This is where nifra skips
1929
2048
  * the per-request async-frame tax - the same win codegen routers get, but without `eval`.
1930
2049
  */
2050
+ // @ts-expect-error TS6133 -- invoked structurally by compiled plans (internal/route-execution.ts)
2051
+ // biome-ignore lint/correctness/noUnusedPrivateClassMembers: invoked structurally by compiled plans (internal/route-execution.ts)
1931
2052
  runBare(entry, ctx, finalize, wrapResponse) {
1932
2053
  let result;
1933
2054
  try {
@@ -1953,7 +2074,7 @@ export class Server {
1953
2074
  * decoration order, same error routing (thrown `Response` = control flow; anything else logs and
1954
2075
  * 500s), same respond semantics (the lifecycle parity suite pins it).
1955
2076
  */
1956
- buildFusedWeb(handler, decorations, contextless) {
2077
+ buildFusedWeb(handler, decorations, contextless, maxBodyBytes) {
1957
2078
  // The fused lanes return to the runtime directly, so the framework's own error renders fold in
1958
2079
  // static headers here rather than through the shared `wrapResponse` seam.
1959
2080
  const logError = (err, ctx) => {
@@ -1973,21 +2094,21 @@ export class Server {
1973
2094
  }
1974
2095
  catch (err) {
1975
2096
  return logError(err, nativeContext
1976
- ? RequestContext.native(source, params, search, this.maxBodyBytes, platform)
1977
- : new RequestContext(source, params, search, signal, budget, platform, this.maxBodyBytes));
2097
+ ? RequestContext.native(source, params, search, maxBodyBytes, platform, this.protoPoisoning)
2098
+ : new RequestContext(source, params, search, signal, budget, platform, maxBodyBytes, this.protoPoisoning));
1978
2099
  }
1979
2100
  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)));
2101
+ return result.then((value) => fusedRespondNoSet(value, this.responseBodyTag, this.staticResponseHeaders, this.onResponseHooks.length === 0), (err) => logError(err, nativeContext
2102
+ ? RequestContext.native(source, params, search, maxBodyBytes, platform, this.protoPoisoning)
2103
+ : new RequestContext(source, params, search, signal, budget, platform, maxBodyBytes, this.protoPoisoning)));
1983
2104
  }
1984
- return fusedRespondNoSet(result, this.responseBodyTag, this.staticResponseHeaders);
2105
+ return fusedRespondNoSet(result, this.responseBodyTag, this.staticResponseHeaders, this.onResponseHooks.length === 0);
1985
2106
  };
1986
2107
  }
1987
2108
  return (source, params, search, signal, budget, platform, nativeContext) => {
1988
2109
  const ctx = nativeContext
1989
- ? RequestContext.native(source, params, search, this.maxBodyBytes, platform)
1990
- : new RequestContext(source, params, search, signal, budget, platform, this.maxBodyBytes);
2110
+ ? RequestContext.native(source, params, search, maxBodyBytes, platform, this.protoPoisoning)
2111
+ : new RequestContext(source, params, search, signal, budget, platform, maxBodyBytes, this.protoPoisoning);
1991
2112
  if (decorations !== undefined)
1992
2113
  Object.assign(ctx, decorations);
1993
2114
  let result;
@@ -1998,15 +2119,15 @@ export class Server {
1998
2119
  return logError(err, ctx);
1999
2120
  }
2000
2121
  if (result instanceof Promise) {
2001
- return result.then((value) => fusedRespond(value, ctx, this.responseBodyTag, this.staticResponseHeaders), (err) => logError(err, ctx));
2122
+ return result.then((value) => fusedRespond(value, ctx, this.responseBodyTag, this.staticResponseHeaders, this.onResponseHooks.length === 0), (err) => logError(err, ctx));
2002
2123
  }
2003
- return fusedRespond(result, ctx, this.responseBodyTag, this.staticResponseHeaders);
2124
+ return fusedRespond(result, ctx, this.responseBodyTag, this.staticResponseHeaders, this.onResponseHooks.length === 0);
2004
2125
  };
2005
2126
  }
2006
2127
  /** Compile the eligible body-only route's parser → validator → handler continuation once. The
2007
2128
  * bounded parser remains shared with the generic lane; only the route-invariant entry lookups and
2008
2129
  * lifecycle dispatch disappear from the common synchronous-validator/synchronous-handler case. */
2009
- buildFusedBodyRunner(handler, bodySchema, decorations) {
2130
+ buildFusedBodyRunner(handler, bodySchema, decorations, maxBodyBytes) {
2010
2131
  const logError = (err, ctx, wrapResponse) => this.bareError(err, ctx, wrapResponse);
2011
2132
  const runHandler = (ctx, finalize, wrapResponse) => {
2012
2133
  if (decorations !== undefined)
@@ -2068,16 +2189,16 @@ export class Server {
2068
2189
  };
2069
2190
  return (source, params, search, signal, budget, platform, nativeContext, finalize, wrapResponse) => {
2070
2191
  const ctx = nativeContext
2071
- ? RequestContext.native(source, params, search, this.maxBodyBytes, platform)
2072
- : new RequestContext(source, params, search, signal, budget, platform, this.maxBodyBytes);
2192
+ ? RequestContext.native(source, params, search, maxBodyBytes, platform, this.protoPoisoning)
2193
+ : new RequestContext(source, params, search, signal, budget, platform, maxBodyBytes, this.protoPoisoning);
2073
2194
  const finish = (value) => runParsed(value, ctx, finalize, wrapResponse);
2074
- return this.readBodyInput(source, finish, wrapResponse, (err) => logError(err, ctx, wrapResponse));
2195
+ return this.readBodyInput(source, maxBodyBytes, finish, wrapResponse, (err) => logError(err, ctx, wrapResponse));
2075
2196
  };
2076
2197
  }
2077
2198
  /** Web adapter wrapper for the shared body runner. Node passes its native finalizer directly through
2078
2199
  * the execution plan, while Web needs the live context to preserve lazy `c.set` controls. */
2079
2200
  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);
2201
+ 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
2202
  }
2082
2203
  /** The fused Web lane for a route whose only lifecycle step is a query schema: parse + validate +
2083
2204
  * handler + respond in one closure, no lifecycle promise when the validator and handler are sync.
@@ -2086,7 +2207,7 @@ export class Server {
2086
2207
  * `validationError(issues)` (recovery hooks disqualify the route from this lane), a thrown
2087
2208
  * `Response` passes through, anything else logs and returns a flat 500, and an async validator
2088
2209
  * falls to a then-chain with the same steps. */
2089
- buildFusedQueryWeb(handler, decorations, querySchema) {
2210
+ buildFusedQueryWeb(handler, decorations, querySchema, maxBodyBytes) {
2090
2211
  // The fused lanes return to the runtime directly, so the framework's own error renders fold in
2091
2212
  // static headers here rather than through the shared `wrapResponse` seam.
2092
2213
  const logError = (err, ctx) => {
@@ -2105,14 +2226,14 @@ export class Server {
2105
2226
  return logError(err, ctx);
2106
2227
  }
2107
2228
  if (result instanceof Promise) {
2108
- return result.then((settled) => fusedRespond(settled, ctx, this.responseBodyTag, this.staticResponseHeaders), (err) => logError(err, ctx));
2229
+ return result.then((settled) => fusedRespond(settled, ctx, this.responseBodyTag, this.staticResponseHeaders, this.onResponseHooks.length === 0), (err) => logError(err, ctx));
2109
2230
  }
2110
- return fusedRespond(result, ctx, this.responseBodyTag, this.staticResponseHeaders);
2231
+ return fusedRespond(result, ctx, this.responseBodyTag, this.staticResponseHeaders, this.onResponseHooks.length === 0);
2111
2232
  };
2112
2233
  return (source, params, search, signal, budget, platform, nativeContext) => {
2113
2234
  const ctx = nativeContext
2114
- ? RequestContext.native(source, params, search, this.maxBodyBytes, platform)
2115
- : new RequestContext(source, params, search, signal, budget, platform, this.maxBodyBytes);
2235
+ ? RequestContext.native(source, params, search, maxBodyBytes, platform, this.protoPoisoning)
2236
+ : new RequestContext(source, params, search, signal, budget, platform, maxBodyBytes, this.protoPoisoning);
2116
2237
  if (decorations !== undefined)
2117
2238
  Object.assign(ctx, decorations);
2118
2239
  let validation;
@@ -2138,6 +2259,8 @@ export class Server {
2138
2259
  this.logRequestError(err, ctx);
2139
2260
  return wrapResponse(jsonError(500, "internal_error"));
2140
2261
  }
2262
+ // @ts-expect-error TS6133 -- invoked structurally by compiled plans (internal/route-execution.ts)
2263
+ // biome-ignore lint/correctness/noUnusedPrivateClassMembers: invoked structurally by compiled plans (internal/route-execution.ts)
2141
2264
  runWithAround(entry, ctx, run, finalize, wrapResponse) {
2142
2265
  let outcome;
2143
2266
  try {
@@ -2165,36 +2288,26 @@ export class Server {
2165
2288
  };
2166
2289
  return dispatch(0);
2167
2290
  }
2291
+ // @ts-expect-error TS6133 -- invoked structurally by compiled plans (internal/route-execution.ts)
2292
+ // biome-ignore lint/correctness/noUnusedPrivateClassMembers: invoked structurally by compiled plans (internal/route-execution.ts)
2168
2293
  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));
2294
+ 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
2295
  }
2171
2296
  /** Shared bounded body framing/parser. Both body lanes use the same trust-boundary checks; only
2172
2297
  * the validation + handler continuation differs. */
2173
- readBodyInput(source, onParsed, wrapResponse, onError) {
2298
+ readBodyInput(source, maxBodyBytes, onParsed, wrapResponse, onError) {
2174
2299
  const contentType = headerOf(source, "content-type") ?? "";
2175
2300
  if (contentType !== "application/json" && !contentType.includes("application/json")) {
2176
2301
  if (isUrlEncodedForm(contentType)) {
2177
- return readBoundedForm(source, this.maxBodyBytes).then((form) => (form instanceof Response ? wrapResponse(form) : onParsed(form)), onError);
2302
+ return readBoundedForm(source, maxBodyBytes).then((form) => (form instanceof Response ? wrapResponse(form) : onParsed(form)), onError);
2178
2303
  }
2179
2304
  return Promise.resolve(wrapResponse(jsonError(415, "unsupported_media_type")));
2180
2305
  }
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
- }
2306
+ // All JSON bodies go through readBoundedJson - the single enforcement point for the framed
2307
+ // fast path (post-read byte re-check), the streaming cap, and the prototype-poisoning guard.
2308
+ // A separate inlined native-json() fast path here would fork the trust boundary in two.
2196
2309
  try {
2197
- return this.readBoundedJson(source).then((parsed) => (parsed instanceof Response ? wrapResponse(parsed) : onParsed(parsed)), onError);
2310
+ return this.readBoundedJson(source, maxBodyBytes, (parsed) => (parsed instanceof Response ? wrapResponse(parsed) : onParsed(parsed)), onError);
2198
2311
  }
2199
2312
  catch (err) {
2200
2313
  return Promise.resolve(onError(err));
@@ -2296,6 +2409,8 @@ export class Server {
2296
2409
  ctx.body = result.value;
2297
2410
  return this.executeHandler(entry, ctx, finalize);
2298
2411
  }
2412
+ // @ts-expect-error TS6133 -- invoked structurally by compiled plans (internal/route-execution.ts)
2413
+ // biome-ignore lint/correctness/noUnusedPrivateClassMembers: invoked structurally by compiled plans (internal/route-execution.ts)
2299
2414
  runQueryOnly(entry, ctx, finalize, wrapResponse) {
2300
2415
  try {
2301
2416
  // Call the validator directly for the raw StandardResult (read `.issues`/`.value`) - skip
@@ -2446,8 +2561,18 @@ export class Server {
2446
2561
  * node-direct primitives); an early/error `Response` (validation, thrown, 500) through `wrapResponse`.
2447
2562
  */
2448
2563
  /** Synchronous until a validator, lifecycle hook, handler, or contract check actually returns a Promise. */
2564
+ // @ts-expect-error TS6133 -- invoked structurally by compiled plans (internal/route-execution.ts)
2565
+ // biome-ignore lint/correctness/noUnusedPrivateClassMembers: invoked structurally by compiled plans (internal/route-execution.ts)
2449
2566
  runLifecycle(entry, source, ctx, finalize, wrapResponse) {
2450
2567
  try {
2568
+ const headersSchema = entry.schema?.headers;
2569
+ if (headersSchema !== undefined) {
2570
+ const validation = headersSchema["~standard"].validate(headerObjectOf(source.headers));
2571
+ if (validation instanceof Promise) {
2572
+ return validation.then((result) => this.runLifecycleAfterHeadersResult(entry, source, ctx, finalize, wrapResponse, result), (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse));
2573
+ }
2574
+ return this.runLifecycleAfterHeadersResult(entry, source, ctx, finalize, wrapResponse, validation);
2575
+ }
2451
2576
  const paramsSchema = entry.schema?.params;
2452
2577
  if (paramsSchema !== undefined) {
2453
2578
  const validation = paramsSchema["~standard"].validate(ctx.params);
@@ -2469,6 +2594,35 @@ export class Server {
2469
2594
  return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse);
2470
2595
  }
2471
2596
  }
2597
+ runLifecycleAfterHeadersResult(entry, source, ctx, finalize, wrapResponse, result) {
2598
+ try {
2599
+ const headersError = this.applyLifecycleValidation(entry, result, ctx, "headers");
2600
+ if (headersError instanceof Promise) {
2601
+ return headersError.then((error) => error === undefined
2602
+ ? this.runLifecycleAfterHeaders(entry, source, ctx, finalize, wrapResponse)
2603
+ : wrapResponse(error), (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse));
2604
+ }
2605
+ return headersError === undefined
2606
+ ? this.runLifecycleAfterHeaders(entry, source, ctx, finalize, wrapResponse)
2607
+ : wrapResponse(headersError);
2608
+ }
2609
+ catch (err) {
2610
+ return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse);
2611
+ }
2612
+ }
2613
+ runLifecycleAfterHeaders(entry, source, ctx, finalize, wrapResponse) {
2614
+ const paramsSchema = entry.schema?.params;
2615
+ if (paramsSchema === undefined) {
2616
+ if (entry.schema?.body === undefined)
2617
+ return this.runQueryAndLifecycle(entry, ctx, finalize, wrapResponse);
2618
+ return this.runLifecycleAfterParams(entry, source, ctx, finalize, wrapResponse, undefined);
2619
+ }
2620
+ const validation = paramsSchema["~standard"].validate(ctx.params);
2621
+ if (validation instanceof Promise) {
2622
+ return validation.then((result) => this.runLifecycleAfterParamsResult(entry, source, ctx, finalize, wrapResponse, result), (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse));
2623
+ }
2624
+ return this.runLifecycleAfterParamsResult(entry, source, ctx, finalize, wrapResponse, validation);
2625
+ }
2472
2626
  /** Registration-specialized query lifecycle: query validation (including recovery) → hooks. */
2473
2627
  runLifecycleQuery(entry, ctx, finalize, wrapResponse) {
2474
2628
  try {
@@ -2492,12 +2646,16 @@ export class Server {
2492
2646
  }
2493
2647
  }
2494
2648
  /** Registration-specialized body lifecycle: bounded body validation (including recovery) → hooks. */
2649
+ // @ts-expect-error TS6133 -- invoked structurally by compiled plans (internal/route-execution.ts)
2650
+ // biome-ignore lint/correctness/noUnusedPrivateClassMembers: invoked structurally by compiled plans (internal/route-execution.ts)
2495
2651
  runLifecycleBody(entry, source, ctx, finalize, wrapResponse) {
2496
2652
  return this.readAndValidateBody(source, entry, ctx).then((bodyError) => bodyError === undefined
2497
2653
  ? this.runLifecycleHooks(entry, ctx, finalize, wrapResponse)
2498
2654
  : wrapResponse(bodyError), (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse));
2499
2655
  }
2500
2656
  /** Registration-specialized body + query lifecycle: bounded body validation → query validation → hooks. */
2657
+ // @ts-expect-error TS6133 -- invoked structurally by compiled plans (internal/route-execution.ts)
2658
+ // biome-ignore lint/correctness/noUnusedPrivateClassMembers: invoked structurally by compiled plans (internal/route-execution.ts)
2501
2659
  runLifecycleBodyQuery(entry, source, ctx, finalize, wrapResponse) {
2502
2660
  return this.readAndValidateBody(source, entry, ctx).then((bodyError) => bodyError === undefined
2503
2661
  ? this.runLifecycleQuery(entry, ctx, finalize, wrapResponse)
@@ -2652,8 +2810,12 @@ export class Server {
2652
2810
  }
2653
2811
  async runLifecycleHooksAsync(entry, ctx, finalize, wrapResponse) {
2654
2812
  try {
2655
- for (const hook of entry.beforeHandle) {
2656
- const outcome = hook(ctx);
2813
+ // Indexed, not `for...of`. An array iterator declared inside an async function stays live
2814
+ // across the `await` below, so neither engine can sink it - unlike the sync lanes above,
2815
+ // where V8 elides it. Measured cost of the iterator form: 43ns per hook on Bun, ~9ns on
2816
+ // Node, on a chain whose hooks return synchronously (the common shape).
2817
+ for (let i = 0; i < entry.beforeHandle.length; i++) {
2818
+ const outcome = entry.beforeHandle[i](ctx);
2657
2819
  const early = outcome instanceof Promise ? await outcome : outcome;
2658
2820
  if (early !== undefined)
2659
2821
  return finalize(early, responseSet(ctx));
@@ -2761,8 +2923,10 @@ export class Server {
2761
2923
  if (err instanceof Response)
2762
2924
  return wrapResponse(err);
2763
2925
  // 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);
2926
+ // Indexed for the same reason as the `beforeHandle` chain above: the iterator would outlive
2927
+ // the `await` and stay heap-allocated on both engines.
2928
+ for (let i = 0; i < entry.onError.length; i++) {
2929
+ const outcome = entry.onError[i](err, ctx);
2766
2930
  const handled = outcome instanceof Promise ? await outcome : outcome;
2767
2931
  if (handled !== undefined)
2768
2932
  return finalize(handled, responseSet(ctx));
@@ -2775,6 +2939,11 @@ export class Server {
2775
2939
  /** Log an unhandled request error to the (redacting) logger - shared by {@link runLifecycle} and the
2776
2940
  * bare fast path ({@link bareError}) so both record the same fields. Never throws; never leaks. */
2777
2941
  logRequestError(err, ctx) {
2942
+ // An error's own text can quote the input that produced it, so how much of it reaches the sink is
2943
+ // the app's call (`errorLogDetail`). The default keeps it: a 500 with no message and no stack is
2944
+ // an incident nobody can diagnose, and the framework already ships the narrower instrument for
2945
+ // the leak - a redacting logger with `valuePatterns`. `"none"` is there for an untrusted sink.
2946
+ const detail = this.errorLogDetail;
2778
2947
  this.logger.error("unhandled request error", {
2779
2948
  method: ctx.req.method,
2780
2949
  path: pathnameOf(ctx.req.url),
@@ -2782,21 +2951,21 @@ export class Server {
2782
2951
  // `detail`, not `message`: the logger uses `message` for its own first argument, so a field of
2783
2952
  // that name is silently overwritten and the thrown error's own text never reaches the sink. It
2784
2953
  // 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,
2954
+ ...(detail === "none" ? {} : { detail: err instanceof Error ? err.message : String(err) }),
2955
+ ...(detail === "full" && err instanceof Error ? { stack: err.stack } : {}),
2787
2956
  });
2788
2957
  }
2789
2958
  async readAndValidateBody(req, entry, ctx) {
2790
2959
  const contentType = headerOf(req, "content-type") ?? "";
2791
2960
  let parsed;
2792
2961
  if (contentType === "application/json" || contentType.includes("application/json")) {
2793
- const json = await this.readBoundedJson(req);
2962
+ const json = await this.readBoundedJson(req, entry.bodyLimit ?? this.maxBodyBytes);
2794
2963
  if (json instanceof Response)
2795
2964
  return json;
2796
2965
  parsed = json;
2797
2966
  }
2798
2967
  else if (isUrlEncodedForm(contentType)) {
2799
- const form = await readBoundedForm(req, this.maxBodyBytes);
2968
+ const form = await readBoundedForm(req, entry.bodyLimit ?? this.maxBodyBytes);
2800
2969
  if (form instanceof Response)
2801
2970
  return form;
2802
2971
  parsed = form;
@@ -2818,6 +2987,8 @@ export class Server {
2818
2987
  ctx.body = value;
2819
2988
  else if (kind === "query")
2820
2989
  ctx.query = value;
2990
+ else if (kind === "headers")
2991
+ ctx.headers = value;
2821
2992
  else
2822
2993
  ctx.params = value;
2823
2994
  };
@@ -2843,7 +3014,9 @@ export class Server {
2843
3014
  ? entry.schema?.body
2844
3015
  : kind === "query"
2845
3016
  ? entry.schema?.query
2846
- : entry.schema?.params;
3017
+ : kind === "params"
3018
+ ? entry.schema?.params
3019
+ : entry.schema?.headers;
2847
3020
  const retried = schema["~standard"].validate(recovery);
2848
3021
  if (retried instanceof Promise) {
2849
3022
  return retried.then((settled) => {
@@ -2858,42 +3031,59 @@ export class Server {
2858
3031
  assign(retried.value);
2859
3032
  return undefined;
2860
3033
  }
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);
3034
+ readBoundedJson(req, maxBodyBytes = this.maxBodyBytes, onResult, onError) {
3035
+ // Not `async`: this is a pass-through, and wrapping the lane's own promise in a coroutine
3036
+ // frame costs an extra microtask hop on every JSON body.
3037
+ return onResult === undefined
3038
+ ? readBoundedJsonSource(req, maxBodyBytes, this.protoPoisoning)
3039
+ : readBoundedJsonSource(req, maxBodyBytes, this.protoPoisoning, onResult, onError);
2877
3040
  }
2878
3041
  /** Adapt one route's compiled execution plan to Bun's already-matched request shape. Route
2879
3042
  * semantics remain in the plan; this closure only supplies native params and deadline fallback. */
2880
3043
  compileBunNativeHandler(entry, paramNames, fused, signal, budget) {
3044
+ // This callback is reached only from Bun's compiled native route table. Bun has already parsed
3045
+ // the HTTP framing, so the JSON lane may retain its native fused `json()` parse without weakening
3046
+ // the portable/adapted source contract (those sources are never marked).
3047
+ const markFramed = (request) => markTrustedBodyFraming(request);
3048
+ // The fused native lane bypasses `runMatched`, so the route's transport byte cap must be
3049
+ // marked here too - otherwise a Bun `listen()` fused route would leave direct `c.req` body
3050
+ // reads uncapped. The non-fused branches go through `fetchMatched` -> `runMatched`, which marks.
3051
+ const bodyLimit = entry.bodyLimit;
3052
+ const inner = fused;
3053
+ const capped = inner === undefined || bodyLimit === undefined
3054
+ ? inner
3055
+ : (source, params, search, fusedSignal, fusedBudget, platform, nativeContext) => {
3056
+ const method = source.method;
3057
+ if (method !== "GET" && method !== "HEAD")
3058
+ markTransportCap(source, bodyLimit);
3059
+ return inner(source, params, search, fusedSignal, fusedBudget, platform, nativeContext);
3060
+ };
2881
3061
  if (paramNames.length === 0) {
2882
- if (fused === undefined) {
2883
- return (request) => this.fetchMatched(request, entry, EMPTY_PARAMS);
3062
+ if (capped === undefined) {
3063
+ return (request) => {
3064
+ markFramed(request);
3065
+ return this.fetchMatched(request, entry, EMPTY_PARAMS);
3066
+ };
2884
3067
  }
2885
3068
  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);
3069
+ return (request) => {
3070
+ markFramed(request);
3071
+ return request.headers.get(NIFRA_DEADLINE_HEADER) !== null
3072
+ ? this.fetchMatched(request, entry, EMPTY_PARAMS)
3073
+ : capped(request, EMPTY_PARAMS, undefined, signal, budget, undefined, true);
3074
+ };
2889
3075
  }
2890
- return (request) => fused(request, EMPTY_PARAMS, undefined, signal, budget, undefined, true);
3076
+ return (request) => {
3077
+ markFramed(request);
3078
+ return capped(request, EMPTY_PARAMS, undefined, signal, budget, undefined, true);
3079
+ };
2891
3080
  }
2892
3081
  const malformed = paramNames.length === 1
2893
3082
  ? (params) => params[paramNames[0]]?.includes("\uFFFD") === true
2894
3083
  : hasReplacementParam;
2895
- if (fused === undefined) {
3084
+ if (capped === undefined) {
2896
3085
  return (request) => {
3086
+ markFramed(request);
2897
3087
  const params = request.params ?? EMPTY_PARAMS;
2898
3088
  if (malformed(params))
2899
3089
  return this.fetchSource(request);
@@ -2902,19 +3092,21 @@ export class Server {
2902
3092
  }
2903
3093
  if (this.acceptInboundDeadlines) {
2904
3094
  return (request) => {
3095
+ markFramed(request);
2905
3096
  const params = request.params ?? EMPTY_PARAMS;
2906
3097
  if (malformed(params))
2907
3098
  return this.fetchSource(request);
2908
3099
  return request.headers.get(NIFRA_DEADLINE_HEADER) !== null
2909
3100
  ? this.fetchMatched(request, entry, params)
2910
- : fused(request, params, undefined, signal, budget, undefined, true);
3101
+ : capped(request, params, undefined, signal, budget, undefined, true);
2911
3102
  };
2912
3103
  }
2913
3104
  return (request) => {
3105
+ markFramed(request);
2914
3106
  const params = request.params ?? EMPTY_PARAMS;
2915
3107
  if (malformed(params))
2916
3108
  return this.fetchSource(request);
2917
- return fused(request, params, undefined, signal, budget, undefined, true);
3109
+ return capped(request, params, undefined, signal, budget, undefined, true);
2918
3110
  };
2919
3111
  }
2920
3112
  /** Compile portable route registrations into Bun's native route table. Apps with request-rewrite
@@ -2980,6 +3172,13 @@ export class Server {
2980
3172
  * `"127.0.0.1"` to bind loopback only - an admin surface, a sidecar, or anything that must not be
2981
3173
  * reachable off the box. Omitting it when you meant to restrict is a real exposure, so it is a
2982
3174
  * first-class option rather than something a caller has to drop down to `Bun.serve` for.
3175
+ *
3176
+ * `idleTimeoutSec` is how long Bun tolerates a connection with no bytes moving before it closes
3177
+ * the socket. Bun's default is **10 seconds**, and a request whose handler is still working sends
3178
+ * nothing - so any endpoint slower than 10s (a render, an export, a long upstream call) has its
3179
+ * connection cut mid-flight regardless of `requestTimeoutMs`. Apps with such endpoints must raise
3180
+ * this above their slowest expected response, which is why it is a first-class option and not a
3181
+ * reason to drop down to `Bun.serve`. `0` disables the timeout entirely; max 255.
2983
3182
  */
2984
3183
  listen(port, options) {
2985
3184
  if (typeof Bun === "undefined") {
@@ -3006,12 +3205,15 @@ export class Server {
3006
3205
  // Spread rather than pass `hostname: undefined` - Bun treats an explicit undefined as a value
3007
3206
  // on some option paths, and omitting is what selects its 0.0.0.0 default.
3008
3207
  const bind = options?.hostname === undefined ? {} : { hostname: options.hostname };
3208
+ // Same reasoning as `bind`: omit rather than pass undefined, so Bun's own default applies.
3209
+ const idle = options?.idleTimeoutSec === undefined ? {} : { idleTimeout: options.idleTimeoutSec };
3009
3210
  const nativeRoutes = wsHandlers === undefined ? this.buildBunNativeRoutes() : undefined;
3010
3211
  const running = (wsHandlers === undefined
3011
3212
  ? Bun.serve({
3012
3213
  port,
3013
3214
  reusePort,
3014
3215
  ...bind,
3216
+ ...idle,
3015
3217
  ...(nativeRoutes === undefined ? {} : { routes: nativeRoutes }),
3016
3218
  fetch: (req, server) => this.fetch(req, bunPeerPlatform(server, req)),
3017
3219
  })
@@ -3019,6 +3221,7 @@ export class Server {
3019
3221
  port,
3020
3222
  reusePort,
3021
3223
  ...bind,
3224
+ ...idle,
3022
3225
  fetch: (req, server) => this.bunFetchWithWebSocket(req, server),
3023
3226
  // Bun's `ServerWebSocket<BunWsData>` is runtime-compatible with the handlers' structural
3024
3227
  // `BunSocket` view (kept local so `Bun.*` types never leak into the published .d.ts); the
@@ -3059,6 +3262,14 @@ export class Server {
3059
3262
  await Bun.sleep(DRAIN_POLL_MS);
3060
3263
  }
3061
3264
  server.stop(server.pendingRequests > 0); // force-close iff stragglers remain past the deadline
3265
+ if (this.stopHooks.length === 0)
3266
+ return;
3267
+ const callbacks = this.stopHooks.map((hook) => Promise.resolve().then(hook));
3268
+ const settled = Promise.allSettled(callbacks);
3269
+ await Promise.race([
3270
+ settled,
3271
+ new Promise((resolve) => setTimeout(resolve, STOP_HOOK_TIMEOUT_MS)),
3272
+ ]);
3062
3273
  }
3063
3274
  installSignalHandlers() {
3064
3275
  // Drain, then let the process exit naturally - the stopped server no longer