@nifrajs/core 2.14.1 → 3.1.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 (138) hide show
  1. package/README.md +4 -0
  2. package/dist/assurance.d.ts +16 -8
  3. package/dist/assurance.d.ts.map +1 -1
  4. package/dist/assurance.js +33 -0
  5. package/dist/assurance.js.map +1 -1
  6. package/dist/edge-kit.d.ts +27 -0
  7. package/dist/edge-kit.d.ts.map +1 -0
  8. package/dist/edge-kit.js +24 -0
  9. package/dist/edge-kit.js.map +1 -0
  10. package/dist/errors.d.ts +1 -1
  11. package/dist/errors.d.ts.map +1 -1
  12. package/dist/errors.js.map +1 -1
  13. package/dist/index.d.ts +1 -1
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +1 -1
  16. package/dist/index.js.map +1 -1
  17. package/dist/internal/route-execution.d.ts +11 -5
  18. package/dist/internal/route-execution.d.ts.map +1 -1
  19. package/dist/internal/route-execution.js +2 -1
  20. package/dist/internal/route-execution.js.map +1 -1
  21. package/dist/response-observer.d.ts +25 -0
  22. package/dist/response-observer.d.ts.map +1 -0
  23. package/dist/response-observer.js +40 -0
  24. package/dist/response-observer.js.map +1 -0
  25. package/dist/security/baseline.d.ts +68 -0
  26. package/dist/security/baseline.d.ts.map +1 -0
  27. package/dist/security/baseline.js +118 -0
  28. package/dist/security/baseline.js.map +1 -0
  29. package/dist/server/bare-error-lane.d.ts +26 -0
  30. package/dist/server/bare-error-lane.d.ts.map +1 -0
  31. package/dist/server/bare-error-lane.js +60 -0
  32. package/dist/server/bare-error-lane.js.map +1 -0
  33. package/dist/server/body.d.ts +7 -0
  34. package/dist/server/body.d.ts.map +1 -1
  35. package/dist/server/body.js +7 -0
  36. package/dist/server/body.js.map +1 -1
  37. package/dist/server/bridge-markers.d.ts +25 -0
  38. package/dist/server/bridge-markers.d.ts.map +1 -0
  39. package/dist/server/bridge-markers.js +25 -0
  40. package/dist/server/bridge-markers.js.map +1 -0
  41. package/dist/server/context.d.ts +6 -1
  42. package/dist/server/context.d.ts.map +1 -1
  43. package/dist/server/header-case.d.ts.map +1 -1
  44. package/dist/server/header-case.js +2 -1
  45. package/dist/server/header-case.js.map +1 -1
  46. package/dist/server/http.d.ts +36 -3
  47. package/dist/server/http.d.ts.map +1 -1
  48. package/dist/server/http.js +49 -3
  49. package/dist/server/http.js.map +1 -1
  50. package/dist/server/install.d.ts +6 -8
  51. package/dist/server/install.d.ts.map +1 -1
  52. package/dist/server/install.js +7 -0
  53. package/dist/server/install.js.map +1 -1
  54. package/dist/server/node-outcome-hook.d.ts +2 -1
  55. package/dist/server/node-outcome-hook.d.ts.map +1 -1
  56. package/dist/server/node-outcome-hook.js.map +1 -1
  57. package/dist/server/node-outcome.d.ts +17 -2
  58. package/dist/server/node-outcome.d.ts.map +1 -1
  59. package/dist/server/node-outcome.js +37 -3
  60. package/dist/server/node-outcome.js.map +1 -1
  61. package/dist/server/query.d.ts +7 -1
  62. package/dist/server/query.d.ts.map +1 -1
  63. package/dist/server/query.js +8 -3
  64. package/dist/server/query.js.map +1 -1
  65. package/dist/server/request-context.d.ts +17 -4
  66. package/dist/server/request-context.d.ts.map +1 -1
  67. package/dist/server/request-context.js +63 -29
  68. package/dist/server/request-context.js.map +1 -1
  69. package/dist/server/respond.d.ts.map +1 -1
  70. package/dist/server/respond.js +19 -2
  71. package/dist/server/respond.js.map +1 -1
  72. package/dist/server/response-contract-lane.d.ts.map +1 -1
  73. package/dist/server/response-contract-lane.js +5 -2
  74. package/dist/server/response-contract-lane.js.map +1 -1
  75. package/dist/server/response-observer-runtime.d.ts +20 -0
  76. package/dist/server/response-observer-runtime.d.ts.map +1 -0
  77. package/dist/server/response-observer-runtime.js +95 -0
  78. package/dist/server/response-observer-runtime.js.map +1 -0
  79. package/dist/server/runtime-core.d.ts +60 -0
  80. package/dist/server/runtime-core.d.ts.map +1 -1
  81. package/dist/server/runtime-core.js +65 -1
  82. package/dist/server/runtime-core.js.map +1 -1
  83. package/dist/server/server-types.d.ts +3 -1
  84. package/dist/server/server-types.d.ts.map +1 -1
  85. package/dist/server/server.d.ts +52 -39
  86. package/dist/server/server.d.ts.map +1 -1
  87. package/dist/server/server.js +267 -271
  88. package/dist/server/server.js.map +1 -1
  89. package/dist/server/websocket.d.ts +20 -4
  90. package/dist/server/websocket.d.ts.map +1 -1
  91. package/dist/server/websocket.js +81 -11
  92. package/dist/server/websocket.js.map +1 -1
  93. package/dist/server/ws-bun.d.ts +1 -1
  94. package/dist/server/ws-bun.d.ts.map +1 -1
  95. package/dist/server/ws-bun.js +12 -8
  96. package/dist/server/ws-bun.js.map +1 -1
  97. package/dist/server.d.ts +2 -1
  98. package/dist/server.d.ts.map +1 -1
  99. package/dist/server.js +2 -1
  100. package/dist/server.js.map +1 -1
  101. package/dist/single-copy-register.d.ts +2 -0
  102. package/dist/single-copy-register.d.ts.map +1 -0
  103. package/dist/single-copy-register.js +24 -0
  104. package/dist/single-copy-register.js.map +1 -0
  105. package/dist/single-copy.d.ts +156 -0
  106. package/dist/single-copy.d.ts.map +1 -0
  107. package/dist/single-copy.js +470 -0
  108. package/dist/single-copy.js.map +1 -0
  109. package/package.json +24 -2
  110. package/src/assurance.ts +58 -1
  111. package/src/edge-kit.ts +27 -0
  112. package/src/errors.ts +1 -0
  113. package/src/index.ts +1 -1
  114. package/src/internal/route-execution.ts +23 -16
  115. package/src/response-observer.ts +64 -0
  116. package/src/security/baseline.ts +172 -0
  117. package/src/server/bare-error-lane.ts +76 -0
  118. package/src/server/body.ts +8 -0
  119. package/src/server/bridge-markers.ts +24 -0
  120. package/src/server/context.ts +6 -1
  121. package/src/server/header-case.ts +3 -1
  122. package/src/server/http.ts +53 -3
  123. package/src/server/install.ts +14 -0
  124. package/src/server/node-outcome-hook.ts +2 -1
  125. package/src/server/node-outcome.ts +44 -3
  126. package/src/server/query.ts +10 -4
  127. package/src/server/request-context.ts +85 -33
  128. package/src/server/respond.ts +26 -1
  129. package/src/server/response-contract-lane.ts +6 -2
  130. package/src/server/response-observer-runtime.ts +135 -0
  131. package/src/server/runtime-core.ts +98 -1
  132. package/src/server/server-types.ts +3 -1
  133. package/src/server/server.ts +436 -367
  134. package/src/server/websocket.ts +127 -17
  135. package/src/server/ws-bun.ts +26 -7
  136. package/src/server.ts +2 -1
  137. package/src/single-copy-register.ts +24 -0
  138. package/src/single-copy.ts +524 -0
@@ -7,107 +7,19 @@ import { compileRouteExecutionPlan, } from "../internal/route-execution.js";
7
7
  import { isSameOriginRequest } from "../internal/same-origin.js";
8
8
  import { compileRoutePattern, decodeRouteParams } from "../router/pattern.js";
9
9
  import { EMPTY_PARAMS, Router } from "../router/router.js";
10
- import { assertByteLimit, markTransportCap, markTrustedBodyFraming, } from "./body.js";
10
+ import { emitRequestErrorLog, renderBareError } from "./bare-error-lane.js";
11
+ import { assertByteLimit, markTransportCap, markTrustedBodyFraming, UNLIMITED_BODY_BYTES, } from "./body.js";
11
12
  import { resolveClientIp } from "./client-ip.js";
12
13
  import { hasLowercaseHeaderKeysMark, headerKeysAllLowercase, markLowercaseHeaderKeys, } from "./header-case.js";
13
14
  import { headerObjectOf } from "./headers.js";
14
- import { jsonError, pathnameOf, urlPartsOf } from "./http.js";
15
+ import { jsonError, pathnameOf, plainError, urlPartsOf } from "./http.js";
15
16
  import { withStaticNodeHeaders } from "./node-outcome.js";
16
- import { recordHeadersView, } from "./node-outcome-hook.js";
17
17
  import { isUrlEncodedForm, queryObjectOf, readBoundedForm, searchOf, } from "./query.js";
18
- import { RequestContext, readBoundedJsonSource } from "./request-context.js";
19
- import { applyStaticResponseHeaders, buildStaticResponseHeaders, fusedRespond, fusedRespondNoSet, knownMutableHeaders, rememberMutableHeaders, taggedResponseBody, toResponse, } from "./respond.js";
20
- import { applyBodyReplacement, withReplacedBody } from "./response-hooks.js";
21
- import { CONTEXT_SEARCH, CONTEXT_SET, EMPTY_RESPONSE_CONTROLS, getNeverAbortSignal, getUnboundedRequestBudget, headerOf, requestOf, } from "./runtime-core.js";
18
+ import { RequestContext, readBodyFramed, readBoundedJsonSource } from "./request-context.js";
19
+ import { applyStaticResponseHeaders, buildStaticResponseHeaders, fusedRespond, fusedRespondNoSet, toResponse, } from "./respond.js";
20
+ import { CONTEXT_SEARCH, CONTEXT_SET, EMPTY_RESPONSE_CONTROLS, getNeverAbortSignal, getUnboundedRequestBudget, headerOf, isResponseResult, requestOf, status, } from "./runtime-core.js";
22
21
  import { normalizeStaticResponseHeaders } from "./static-headers.js";
23
- const HEADER_MUTABILITY_PROBE = "x-nifra-header-probe";
24
- const GUARDED_RESPONSE_HEADERS = new WeakSet();
25
- /**
26
- * Detect a guarded Web Headers object before invoking user code. Retrying after catching any
27
- * TypeError is observably wrong: user hooks are allowed to throw TypeError themselves, and a retry
28
- * runs those hooks twice. The hot path never reaches the probe: every framework-constructed
29
- * Response stamps its headers as known-mutable at construction (see respond.ts), so only a
30
- * handler-returned foreign `Response` - the case that can actually be guarded - pays it, once per
31
- * headers object. The probe uses a valid private header name and restores a pre-existing value, so
32
- * the fallback is limited to the actual immutable/guarded-header case.
33
- */
34
- function hasMutableResponseHeaders(headers) {
35
- if (knownMutableHeaders(headers))
36
- return true;
37
- if (GUARDED_RESPONSE_HEADERS.has(headers))
38
- return false;
39
- let previous = null;
40
- try {
41
- previous = headers.get(HEADER_MUTABILITY_PROBE);
42
- headers.set(HEADER_MUTABILITY_PROBE, "1");
43
- if (previous === null)
44
- headers.delete(HEADER_MUTABILITY_PROBE);
45
- else
46
- headers.set(HEADER_MUTABILITY_PROBE, previous);
47
- rememberMutableHeaders(headers);
48
- return true;
49
- }
50
- catch {
51
- try {
52
- if (previous === null)
53
- headers.delete(HEADER_MUTABILITY_PROBE);
54
- else
55
- headers.set(HEADER_MUTABILITY_PROBE, previous);
56
- }
57
- catch {
58
- // The guarded object rejected the cleanup too; the clone below is authoritative.
59
- }
60
- GUARDED_RESPONSE_HEADERS.add(headers);
61
- return false;
62
- }
63
- }
64
- /** Adapt a portable {@link ResponseBodyHook} into the Web `onResponse` walk. Only a Response
65
- * carrying the framework-buffered body tag participates - a raw or streamed Response is skipped by
66
- * contract, never drained. */
67
- function webResponseBodyHook(fn, owners) {
68
- return (response, req) => {
69
- const body = taggedResponseBody(response, owners);
70
- if (body === undefined)
71
- return response;
72
- const out = fn(body, response.headers, webRequestView(req), response.status);
73
- if (out instanceof Promise)
74
- return out.then((replaced) => withReplacedBody(response, replaced));
75
- return withReplacedBody(response, out);
76
- };
77
- }
78
- /** Minimal request view over a Web `Request` for portable header hooks on the Web serving paths. */
79
- function webRequestView(req) {
80
- return { method: req.method, url: req.url, header: (name) => req.headers.get(name) };
81
- }
82
- /**
83
- * Adapt a portable {@link ResponseHeadersHook} into the Web `onResponse` walk: run it against the
84
- * response's own `Headers` in place (no clone). A response whose headers are GUARDED (a raw
85
- * `fetch()`ed Response returned by a handler) throws on the FIRST mutation, so nothing was applied
86
- * yet - rerun once against a mutable copy. Async hooks should perform their mutations before their
87
- * first await for the guard fallback to cover them.
88
- */
89
- function webResponseHeadersHook(fn) {
90
- return (response, req) => {
91
- const view = webRequestView(req);
92
- if (!hasMutableResponseHeaders(response.headers)) {
93
- const clone = new Response(response.body, response);
94
- const out = fn(clone.headers, view, clone.status);
95
- return out instanceof Promise ? out.then(() => clone) : clone;
96
- }
97
- const out = fn(response.headers, view, response.status);
98
- return out instanceof Promise ? out.then(() => response) : response;
99
- };
100
- }
101
- /** Adapt a raw-response fallback hook. Tagged framework payloads already ran through the body tier,
102
- * while untagged Responses include streams, proxied fetches, and framework-generated error responses. */
103
- function webResponseRawHook(fn, owners) {
104
- return (response, req) => {
105
- if (taggedResponseBody(response, owners) !== undefined)
106
- return response;
107
- return fn(response, req);
108
- };
109
- }
110
- import { INSTALL_EFFECT_LEDGER, INSTALL_IDEMPOTENCY, INSTALL_MCP, INSTALL_NODE_DIRECT, INSTALL_RESPONSE_CONTRACT, INSTALL_SSE, INSTALL_WS, } from "./install.js";
22
+ import { INSTALL_EFFECT_LEDGER, INSTALL_IDEMPOTENCY, INSTALL_MCP, INSTALL_NODE_DIRECT, INSTALL_RESPONSE_CONTRACT, INSTALL_RESPONSE_OBSERVER, INSTALL_SSE, INSTALL_WS, NODE_NATIVE_MOUNT, RESOLVE_NODE_MOUNT, } from "./install.js";
111
23
  import { jsonLogger } from "./logger.js";
112
24
  const functionToString = Function.prototype.toString;
113
25
  const CONTEXTLESS_ARROW = /^(?:async\s*)?\(\s*\)\s*(?::[\s\S]*?)?=>/;
@@ -162,12 +74,18 @@ const STOP_HOOK_TIMEOUT_MS = 5_000;
162
74
  * owner, shared with the server-function mount in `@nifrajs/web` - the two used to answer differently
163
75
  * for the same request, so a browser that could open a socket was told its POST was cross-origin. */
164
76
  const wsSameOrigin = isSameOriginRequest;
165
- function validationError(issues) {
77
+ function validationIssues(issues) {
166
78
  const serialized = issues.map((issue) => {
167
79
  const path = issue.path?.map((seg) => String(typeof seg === "object" ? seg.key : seg));
168
80
  return path !== undefined ? { message: issue.message, path } : { message: issue.message };
169
81
  });
170
- return Response.json({ ok: false, error: "validation", issues: serialized }, { status: 422 });
82
+ return { ok: false, error: "validation", issues: serialized };
83
+ }
84
+ /** The 422 as plain data - the shape every lane's response wrapper takes, so a rejected body costs
85
+ * what an accepted one costs: on Node it is written straight to the socket with a `content-length`
86
+ * instead of being built as a `Response` and drained back out. */
87
+ export function plainValidationError(issues) {
88
+ return status(422, validationIssues(issues));
171
89
  }
172
90
  // `jsonError`, `urlPartsOf`, `pathnameOf` moved to `./http.ts` (a dependency-free leaf shared with the
173
91
  // opt-in request lanes); re-exported so existing importers keep resolving from here.
@@ -210,6 +128,20 @@ function stripMountPrefix(request, prefix) {
210
128
  function responseSet(ctx) {
211
129
  return ctx[CONTEXT_SET]() ?? EMPTY_RESPONSE_CONTROLS;
212
130
  }
131
+ /**
132
+ * A `derive` that answered with a response rather than a context extension - `status(...)`, or a bare
133
+ * `Response`. The request ends there, on the same lane a `beforeHandle`'s early return takes.
134
+ *
135
+ * `derive` is the one stage that could not exit early: `beforeHandle` says "done" by returning a
136
+ * value, but a derive's return value IS the extension, so the only way out was `throw`ing a
137
+ * `Response` - which measured as the single most expensive way to answer (the `Response` object
138
+ * costs ~40% of the rejection lane; see `PlainRender`). Neither shape had a meaning here before:
139
+ * `Object.assign(ctx, response)` copies nothing (a `Response`'s properties live on its prototype),
140
+ * so a derive that returned one silently fell through to the handler it meant to prevent.
141
+ */
142
+ function isDeriveEarlyExit(extension) {
143
+ return isResponseResult(extension) || extension instanceof Response;
144
+ }
213
145
  function isContextlessNoArgArrow(handler) {
214
146
  if (handler.length !== 0)
215
147
  return false;
@@ -220,8 +152,15 @@ function isContextlessNoArgArrow(handler) {
220
152
  return false;
221
153
  }
222
154
  }
155
+ /**
156
+ * The Web lanes' `wrapResponse` input, as a `Response`. A plain-data carrier ({@link plainError},
157
+ * `status(...)`) is rendered on the ordinary JSON lane rather than through its own `toResponse`, so
158
+ * it picks up the same prebuilt init a handler's plain return uses. `EMPTY_RESPONSE_CONTROLS` because
159
+ * these renders happen where no request context exists - before routing, or after it was abandoned.
160
+ */
161
+ const webResponseOf = (result) => result instanceof Response ? result : toResponse(result, EMPTY_RESPONSE_CONTROLS);
223
162
  // Stable module-level finalizers so `fetch`/`resolveNode` allocate no per-request closures.
224
- const IDENTITY_RESPONSE = (response) => response;
163
+ const IDENTITY_RESPONSE = (response) => webResponseOf(response);
225
164
  const RESPONSE_TIMEOUT = () => jsonError(503, "request_timeout");
226
165
  // The unused-order-scoped-hook audit (recorder, seal check, stack capture, message) is a dev-time
227
166
  // diagnostic. Every guard below keeps the INLINE literal `process.env.NODE_ENV !== "production"` at the
@@ -525,6 +464,11 @@ export class Server {
525
464
  * declared *after* this call, and reaches none declared before. One added after the last route
526
465
  * (e.g. on the app returned by a route-registering factory) covers nothing - see
527
466
  * {@link ServerOptions.unusedScopedHooks}.
467
+ *
468
+ * Returning a `status(...)` (or a `Response`) instead of an extension ends the request there - the
469
+ * guard shape, and the cheap one: it renders as plain data on the same lane a handler's return
470
+ * takes, where `throw new Response(...)` pays ~40% of the rejection lane to build the object. That
471
+ * branch is control flow, so it does not widen the derived context type.
528
472
  */
529
473
  derive(fn) {
530
474
  this.assertConfigurable("derive()");
@@ -671,19 +615,33 @@ export class Server {
671
615
  * hook owns part of the header state - into a hook that runs in the right order. */
672
616
  addStaticResponseHeaders(record) {
673
617
  if (this.onResponseHooks.length > 0) {
674
- return this.onResponseHeaders((headers) => {
675
- for (const name of Object.keys(record)) {
676
- if (!headers.has(name))
677
- headers.set(name, record[name]);
678
- }
679
- });
618
+ const observerHeaders = this.onResponseHeaders;
619
+ if (observerHeaders !== undefined) {
620
+ observerHeaders.call(this, (headers) => {
621
+ for (const name of Object.keys(record)) {
622
+ if (!headers.has(name))
623
+ headers.set(name, record[name]);
624
+ }
625
+ });
626
+ return this;
627
+ }
628
+ // Route the hook through the same seam the static tier uses. On a framework response (headers
629
+ // stamped mutable) it applies the defaults in place and returns the SAME object, so the
630
+ // serialized-body marker survives for later `onResponseBody`/`onResponseRaw` observers and the
631
+ // Node writer; reconstructing a `Response` here would strip that marker and reclassify the Node
632
+ // outcome from `json` to a generic `response`. A guarded foreign response takes its clone path.
633
+ const statics = buildStaticResponseHeaders(record);
634
+ this.onResponseHooks.push((response) => applyStaticResponseHeaders(response, statics));
635
+ this.onNodeResponseHooks.push(undefined);
636
+ this.nodeResponseHooksComplete = false;
637
+ return this;
680
638
  }
681
639
  const merged = this.staticResponseHeaders === undefined
682
640
  ? record
683
641
  : { ...this.staticResponseHeaders.record, ...record };
684
642
  this.staticResponseHeaders = buildStaticResponseHeaders(merged);
685
643
  const statics = this.staticResponseHeaders;
686
- this.wrapWebResponse = (response) => applyStaticResponseHeaders(response, statics);
644
+ this.wrapWebResponse = (response) => applyStaticResponseHeaders(webResponseOf(response), statics);
687
645
  this.webResponseTimeout = () => applyStaticResponseHeaders(RESPONSE_TIMEOUT(), statics);
688
646
  return this;
689
647
  }
@@ -694,58 +652,14 @@ export class Server {
694
652
  }
695
653
  return this.responseBodyTag;
696
654
  }
697
- /**
698
- * Register a response transform for raw/streamed Responses. Framework-serialized payloads stay on
699
- * the body tier; this hook is only entered for untagged Responses (streams, proxied fetches, and
700
- * framework-generated error responses). A no-op native twin keeps buffered JSON on the direct lane.
701
- */
702
- onResponseRaw(fn) {
703
- this.assertConfigurable("onResponseRaw()");
704
- this.enableResponseBodyTagging();
705
- this.onResponseHooks.push(webResponseRawHook(fn, this.responseBodyOwners));
706
- this.onNodeResponseHooks.push(() => undefined);
707
- return this;
708
- }
709
- /**
710
- * Register a PORTABLE header-only response hook - one implementation, fast on every runtime.
711
- *
712
- * The hook receives a mutable case-insensitive header view, a minimal request view, and the
713
- * status. On the Web serving paths it runs inside the normal `onResponse` walk against the
714
- * response's own `Headers` (mutating in place - no clone). On Node it self-pairs as a native
715
- * response hook against the outcome's plain header record, so registering one NEVER forces the
716
- * Node adapter off its direct socket writer the way a full `onResponse(res: Response)` hook does.
717
- * Prefer this over `onResponse` whenever the middleware only reads/writes headers.
718
- */
719
- onResponseHeaders(fn) {
720
- this.assertConfigurable("onResponseHeaders()");
721
- this.onResponseHooks.push(webResponseHeadersHook(fn));
722
- this.onNodeResponseHooks.push((response, req) => fn(recordHeadersView(response), req, response.status));
723
- return this;
724
- }
725
- /**
726
- * Register a PORTABLE post-serialization body hook - the payload tier. The hook receives the
727
- * FINAL framework-serialized bytes (plus the mutable header view and status) and may return
728
- * replacement bytes; `undefined` keeps the body. On the Node direct writer the bytes come
729
- * straight off the outcome record; on the Web serving paths they ride the framework-built
730
- * Response as a tag, so no body stream is ever drained on any runtime. A handler-returned raw
731
- * `Response` (proxied fetch, SSE, streamed SSR) is skipped by contract - transforming those is
732
- * what the full `onResponse` hook is for.
733
- */
734
- onResponseBody(fn) {
735
- this.assertConfigurable("onResponseBody()");
736
- this.enableResponseBodyTagging();
737
- this.onResponseHooks.push(webResponseBodyHook(fn, this.responseBodyOwners));
738
- this.onNodeResponseHooks.push((response, req) => {
739
- const body = response.body;
740
- if (body === null)
741
- return undefined;
742
- const out = fn(body, recordHeadersView(response), req, response.status);
743
- if (out instanceof Promise)
744
- return out.then((replaced) => applyBodyReplacement(response, replaced));
745
- applyBodyReplacement(response, out);
746
- return undefined;
747
- });
748
- return this;
655
+ responseObserverMethods() {
656
+ const methods = this;
657
+ if (typeof methods.onResponseHeaders !== "function" ||
658
+ typeof methods.onResponseBody !== "function" ||
659
+ typeof methods.onResponseRaw !== "function") {
660
+ throw new TypeError("response observation requires `.use(responseObserver())` or an observer-enabled middleware");
661
+ }
662
+ return methods;
749
663
  }
750
664
  /** Observe the terminal response after all transformations. Observers are ordered and fail-open. */
751
665
  onResponseFinalized(fn) {
@@ -791,6 +705,15 @@ export class Server {
791
705
  }
792
706
  this.globalAssurance.push(...evidence.filter((item) => item.scope === "global"));
793
707
  this.activeAssurance.push(...evidence.filter((item) => item.scope === "subsequent"));
708
+ const responseObserver = arg[INSTALL_RESPONSE_OBSERVER];
709
+ if (responseObserver !== undefined) {
710
+ if (typeof responseObserver !== "object" ||
711
+ responseObserver === null ||
712
+ typeof responseObserver.install !== "function") {
713
+ throw new TypeError("response observer middleware has an invalid runtime");
714
+ }
715
+ this[INSTALL_RESPONSE_OBSERVER](responseObserver);
716
+ }
794
717
  if (arg.onRequest !== undefined) {
795
718
  this.assertConfigurable("onRequest()");
796
719
  this.onRequestHooks.push(arg.onRequest);
@@ -824,11 +747,11 @@ export class Server {
824
747
  if (arg.responseHeaders !== undefined)
825
748
  this.responseHeaders(arg.responseHeaders);
826
749
  if (arg.onResponseHeaders !== undefined)
827
- this.onResponseHeaders(arg.onResponseHeaders);
750
+ this.responseObserverMethods().onResponseHeaders(arg.onResponseHeaders);
828
751
  if (arg.onResponseBody !== undefined)
829
- this.onResponseBody(arg.onResponseBody);
752
+ this.responseObserverMethods().onResponseBody(arg.onResponseBody);
830
753
  if (arg.onResponseRaw !== undefined)
831
- this.onResponseRaw(arg.onResponseRaw);
754
+ this.responseObserverMethods().onResponseRaw(arg.onResponseRaw);
832
755
  if (arg.onResponseFinalized !== undefined)
833
756
  this.onResponseFinalized(arg.onResponseFinalized);
834
757
  if (arg.onError !== undefined)
@@ -935,6 +858,9 @@ export class Server {
935
858
  // Boot-time guard: the WS runtime is a subpath (`@nifrajs/core/ws`) so no-WebSocket apps don't
936
859
  // bundle it. Registration is the loud, early failure point - never the first connection.
937
860
  const runtime = requireWsRuntime(this.wsRuntime);
861
+ if (handler.validateSend === true && handler.sendSchema === undefined) {
862
+ throw new RouteConfigError("INVALID_WS_SEND_VALIDATION", `route WS ${path}: validateSend requires sendSchema`);
863
+ }
938
864
  this.topics ??= runtime.createTopics();
939
865
  // A `messageSchema` wraps `message` with validation once, here - every adapter then dispatches
940
866
  // already-validated, typed messages (Bun/Deno/Node/Workers) with no per-adapter code.
@@ -987,6 +913,8 @@ export class Server {
987
913
  }
988
914
  prepareRoute(method, path, schema, handler) {
989
915
  const pattern = compileRoutePattern(path);
916
+ // Resolved here, once: `undefined` past this point means the route declared `"unlimited"`, NOT
917
+ // "nothing declared". Readers fall back to UNLIMITED_BODY_BYTES accordingly.
990
918
  let bodyLimit = this.maxBodyBytes;
991
919
  const invalidBodyLimit = (message) => {
992
920
  throw new RouteConfigError("INVALID_BODY_LIMIT", `route ${method} ${path}: ${message}`);
@@ -999,6 +927,12 @@ export class Server {
999
927
  schema.bodyLimitReason.trim().length === 0) {
1000
928
  invalidBodyLimit('bodyLimit: "unlimited" requires a non-empty bodyLimitReason');
1001
929
  }
930
+ if (schema.body !== undefined) {
931
+ invalidBodyLimit('bodyLimit: "unlimited" cannot be used with a body schema; use a finite cap');
932
+ }
933
+ if (schema.idempotency !== undefined) {
934
+ invalidBodyLimit('bodyLimit: "unlimited" cannot be used with idempotency');
935
+ }
1002
936
  bodyLimit = undefined;
1003
937
  }
1004
938
  else if (schema?.bodyLimit !== undefined) {
@@ -1096,12 +1030,12 @@ export class Server {
1096
1030
  // handler continuation once. The runner keeps the bounded parser and all error semantics while
1097
1031
  // avoiding the generic entry/schema/lifecycle dispatch on Bun, Deno, and Node-direct.
1098
1032
  const fusedBodyRunner = fusedBody
1099
- ? this.buildFusedBodyRunner(handler, schema.body, hasDecorations ? routeDecorations : undefined, bodyLimit ?? this.maxBodyBytes)
1033
+ ? this.buildFusedBodyRunner(handler, schema.body, hasDecorations ? routeDecorations : undefined, bodyLimit ?? UNLIMITED_BODY_BYTES)
1100
1034
  : undefined;
1101
1035
  const fusedWeb = bare && this.aroundHooks.length === 0
1102
- ? this.buildFusedWeb(handler, hasDecorations ? routeDecorations : undefined, isContextlessNoArgArrow(handler), bodyLimit ?? this.maxBodyBytes)
1036
+ ? this.buildFusedWeb(handler, hasDecorations ? routeDecorations : undefined, isContextlessNoArgArrow(handler), bodyLimit ?? UNLIMITED_BODY_BYTES)
1103
1037
  : fusedQuery
1104
- ? this.buildFusedQueryWeb(handler, hasDecorations ? routeDecorations : undefined, schema.query, bodyLimit ?? this.maxBodyBytes)
1038
+ ? this.buildFusedQueryWeb(handler, hasDecorations ? routeDecorations : undefined, schema.query, bodyLimit ?? UNLIMITED_BODY_BYTES)
1105
1039
  : fusedBody
1106
1040
  ? this.buildFusedBodyWeb(fusedBodyRunner)
1107
1041
  : undefined;
@@ -1209,7 +1143,18 @@ export class Server {
1209
1143
  });
1210
1144
  routeAssurance.push(Object.freeze(declared));
1211
1145
  }
1212
- if (schema?.body !== undefined) {
1146
+ // Two ways a route proves its body is bounded, both enforced by the core at read time:
1147
+ // `schema.body` (the validated read is bounded) and an explicit finite `schema.bodyLimit` (the
1148
+ // transport cap shadows every reader, including a raw `c.req.body` stream that a Content-Length
1149
+ // middleware cannot bound).
1150
+ // `bodyLimit: "unlimited"` is rejected above for schema and idempotency routes. The remaining
1151
+ // exemption is schema-less streaming/upload work, so it publishes NOTHING: there is no finite
1152
+ // framework byte ceiling for assurance to attest and the application protocol owns that bound.
1153
+ // The server-wide `maxBodyBytes` default is not evidence either. It applies to every route
1154
+ // whether or not anyone thought about it, so publishing it would make the id pass everywhere and
1155
+ // prove nothing; this evidence marks a bound the route chose.
1156
+ const unlimitedBody = schema?.bodyLimit === "unlimited";
1157
+ if (!unlimitedBody && (schema?.body !== undefined || typeof schema?.bodyLimit === "number")) {
1213
1158
  routeAssurance.push(Object.freeze({
1214
1159
  id: NIFRA_ASSURANCE_IDS.BODY_BOUNDED,
1215
1160
  source: "route-schema",
@@ -1244,6 +1189,29 @@ export class Server {
1244
1189
  this.assertConfigurable("responseContract()");
1245
1190
  this.responseContractRuntime = runtime;
1246
1191
  }
1192
+ /** @internal Symbol-keyed install seam for the response observer plugin. */
1193
+ [INSTALL_RESPONSE_OBSERVER](runtime) {
1194
+ this.assertConfigurable("responseObserver()");
1195
+ const methods = runtime.install({
1196
+ assertConfigurable: (operation) => this.assertConfigurable(operation),
1197
+ addResponseHook: (web, node) => {
1198
+ this.onResponseHooks.push(web);
1199
+ this.onNodeResponseHooks.push(node);
1200
+ if (node === undefined)
1201
+ this.nodeResponseHooksComplete = false;
1202
+ },
1203
+ enableResponseBodyTagging: () => {
1204
+ if (this.responseBodyTag === undefined) {
1205
+ this.responseBodyTag = Object.freeze({});
1206
+ this.responseBodyOwners.add(this.responseBodyTag);
1207
+ }
1208
+ return this.responseBodyTag;
1209
+ },
1210
+ responseBodyOwners: () => this.responseBodyOwners,
1211
+ });
1212
+ Object.assign(this, methods);
1213
+ return methods;
1214
+ }
1247
1215
  /** @internal Symbol-keyed install seam for the `idempotency()` plugin. Off the public typed surface. */
1248
1216
  [INSTALL_IDEMPOTENCY](runtime) {
1249
1217
  this.assertConfigurable("idempotency()");
@@ -1374,13 +1342,13 @@ export class Server {
1374
1342
  // Rebuild with the SAME builder that produced the closure - rebinding a query-fused route as
1375
1343
  // bare would silently drop its validation.
1376
1344
  const fusedBody = entry.execution.fusedLane === "body"
1377
- ? this.buildFusedBodyRunner(entry.handler, entry.schema?.body, entry.hasDecorations ? entry.decorations : undefined, entry.bodyLimit ?? this.maxBodyBytes)
1345
+ ? this.buildFusedBodyRunner(entry.handler, entry.schema?.body, entry.hasDecorations ? entry.decorations : undefined, entry.bodyLimit ?? UNLIMITED_BODY_BYTES)
1378
1346
  : undefined;
1379
1347
  const fusedWeb = entry.execution.fusedLane === "body"
1380
1348
  ? this.buildFusedBodyWeb(fusedBody)
1381
1349
  : entry.execution.fusedLane === "query"
1382
- ? this.buildFusedQueryWeb(entry.handler, entry.hasDecorations ? entry.decorations : undefined, entry.schema?.query, entry.bodyLimit ?? this.maxBodyBytes)
1383
- : this.buildFusedWeb(entry.handler, entry.hasDecorations ? entry.decorations : undefined, isContextlessNoArgArrow(entry.handler), entry.bodyLimit ?? this.maxBodyBytes);
1350
+ ? this.buildFusedQueryWeb(entry.handler, entry.hasDecorations ? entry.decorations : undefined, entry.schema?.query, entry.bodyLimit ?? UNLIMITED_BODY_BYTES)
1351
+ : this.buildFusedWeb(entry.handler, entry.hasDecorations ? entry.decorations : undefined, isContextlessNoArgArrow(entry.handler), entry.bodyLimit ?? UNLIMITED_BODY_BYTES);
1384
1352
  return {
1385
1353
  ...route,
1386
1354
  entry: {
@@ -1498,6 +1466,42 @@ export class Server {
1498
1466
  release();
1499
1467
  return outcome;
1500
1468
  }
1469
+ /**
1470
+ * Select a mounted handler's native Node lane without materializing a Web Request. This is an
1471
+ * adapter seam, not a second lifecycle: any app-wide behavior that the ordinary mount path would
1472
+ * run makes the capability ineligible and the caller falls back to `resolveNodeSource`. Typed
1473
+ * routes keep precedence exactly as `routeAndRun` does, including the existing mount behavior for
1474
+ * a path whose registered route rejects this method.
1475
+ */
1476
+ [RESOLVE_NODE_MOUNT](source) {
1477
+ if (this.onRequestHooks.length > 0 ||
1478
+ this.onResponseHooks.length > 0 ||
1479
+ this.onResponseFinalizedHooks.length > 0 ||
1480
+ this.capacityGate !== undefined ||
1481
+ this.staticResponseHeaders !== undefined ||
1482
+ this.requestTimeoutMs !== 0 ||
1483
+ this.acceptInboundDeadlines ||
1484
+ this.clientIpTrust !== undefined) {
1485
+ return undefined;
1486
+ }
1487
+ const parts = source.urlParts ?? urlPartsOf(source.url);
1488
+ const match = this.catalog.find(source.method, parts.pathname);
1489
+ if (match.found)
1490
+ return undefined;
1491
+ for (const mount of this.fetchMounts) {
1492
+ if (!underMountPrefix(parts.pathname, mount.path))
1493
+ continue;
1494
+ const candidate = mount.handler[NODE_NATIVE_MOUNT];
1495
+ return typeof candidate === "function"
1496
+ ? {
1497
+ handler: candidate,
1498
+ path: mount.path,
1499
+ stripPrefix: mount.stripPrefix,
1500
+ }
1501
+ : undefined;
1502
+ }
1503
+ return undefined;
1504
+ }
1501
1505
  /**
1502
1506
  * Like {@link fetch}, but renders a plain-data result **without** building a Web `Response` - the
1503
1507
  * `@nifrajs/node` adapter serializes the returned primitives straight to the socket, skipping the undici
@@ -2037,15 +2041,18 @@ export class Server {
2037
2041
  : wrapResponse(mounted);
2038
2042
  }
2039
2043
  if (match.reason === "method-not-allowed") {
2040
- return wrapResponse(jsonError(405, "method_not_allowed", { Allow: match.allowed.join(", ") }));
2044
+ return wrapResponse(
2045
+ // Lowercase on purpose: a plain render's headers go into the node outcome record verbatim,
2046
+ // and every other name in that record is lowercase (the Web lane normalizes either way).
2047
+ plainError(405, "method_not_allowed", { allow: match.allowed.join(", ") }));
2041
2048
  }
2042
- return wrapResponse(jsonError(404, "not_found"));
2049
+ return wrapResponse(plainError(404, "not_found"));
2043
2050
  }
2044
2051
  // Inspect only captured values for escapes. Scanning the full pathname repeated work the router
2045
2052
  // already did and made every plain dynamic route pay for unrelated static path bytes.
2046
2053
  const params = match.params === EMPTY_PARAMS ? match.params : decodeRouteParams(match.params);
2047
2054
  if (params === null) {
2048
- return wrapResponse(jsonError(400, "malformed_path"));
2055
+ return wrapResponse(plainError(400, "malformed_path"));
2049
2056
  }
2050
2057
  return this.runMatched(source, platform, match.payload, params, search, finalize, wrapResponse, onTimeout, webFast);
2051
2058
  }
@@ -2080,7 +2087,7 @@ export class Server {
2080
2087
  // The runtime is always present when a route resolved idempotency (enforced at registration).
2081
2088
  if (entry.idempotent !== undefined && this.idempotencyRuntime !== undefined) {
2082
2089
  return this.idempotencyRuntime.run(entry.idempotent, requestOf(source), platform, entry, params, search, wrapResponse, {
2083
- maxBodyBytes: entry.bodyLimit ?? this.maxBodyBytes,
2090
+ maxBodyBytes: entry.bodyLimit ?? UNLIMITED_BODY_BYTES,
2084
2091
  runLanes: (buffered, plat, ent, prm, srch) => this.idempotencyRunLanes(buffered, plat, ent, prm, srch),
2085
2092
  });
2086
2093
  }
@@ -2106,7 +2113,7 @@ export class Server {
2106
2113
  ? undefined
2107
2114
  : admitDeadline(source.headers, this.deadlineAdmissionOptions);
2108
2115
  if (admission !== undefined && !admission.ok) {
2109
- return wrapResponse(jsonError(admission.status, admission.reason));
2116
+ return wrapResponse(plainError(admission.status, admission.reason));
2110
2117
  }
2111
2118
  const effectiveTimeoutMs = admission?.timeoutMs ?? 0;
2112
2119
  // Only allocate a controller for a finite budget; the historical no-timeout path remains
@@ -2131,7 +2138,7 @@ export class Server {
2131
2138
  // already complete and can't time out, so it's returned as-is (no 503 race, no promise).
2132
2139
  if (controller !== undefined && outcome instanceof Promise) {
2133
2140
  const timedOut = admission?.inherited === true
2134
- ? () => wrapResponse(jsonError(504, "deadline_exceeded"))
2141
+ ? () => wrapResponse(plainError(504, "deadline_exceeded"))
2135
2142
  : onTimeout;
2136
2143
  return this.withTimeout(outcome, controller, timedOut, Math.max(0, Math.ceil(budget.remaining())));
2137
2144
  }
@@ -2162,9 +2169,13 @@ export class Server {
2162
2169
  contextlessBareError(err, source, params, search, signal, budget, platform, wrapResponse) {
2163
2170
  if (err instanceof Response)
2164
2171
  return wrapResponse(err);
2172
+ // Handed over as the plain data it is: `wrapResponse` renders it on the JSON lane (no `Response`
2173
+ // built on Node). There is no context here by construction, so there is no `c.set` to merge.
2174
+ if (isResponseResult(err))
2175
+ return wrapResponse(err);
2165
2176
  const ctx = new RequestContext(source, params, search, signal, budget, platform, this.maxBodyBytes, this.protoPoisoning);
2166
2177
  this.logRequestError(err, ctx);
2167
- return wrapResponse(jsonError(500, "internal_error"));
2178
+ return wrapResponse(plainError(500, "internal_error"));
2168
2179
  }
2169
2180
  /**
2170
2181
  * The synchronous fast path selected by a route's execution plan: apply static decorations, call the
@@ -2184,11 +2195,11 @@ export class Server {
2184
2195
  result = entry.handler(ctx);
2185
2196
  }
2186
2197
  catch (err) {
2187
- return this.bareError(err, ctx, wrapResponse);
2198
+ return this.bareError(err, ctx, finalize, wrapResponse);
2188
2199
  }
2189
2200
  if (result instanceof Promise) {
2190
2201
  // Async handler on an otherwise-bare route: finish on a microtask, with the same error handling.
2191
- return result.then((value) => finalize(value, responseSet(ctx)), (err) => this.bareError(err, ctx, wrapResponse));
2202
+ return result.then((value) => finalize(value, responseSet(ctx)), (err) => this.bareError(err, ctx, finalize, wrapResponse));
2192
2203
  }
2193
2204
  return finalize(result, responseSet(ctx));
2194
2205
  }
@@ -2202,14 +2213,10 @@ export class Server {
2202
2213
  * 500s), same respond semantics (the lifecycle parity suite pins it).
2203
2214
  */
2204
2215
  buildFusedWeb(handler, decorations, contextless, maxBodyBytes) {
2205
- // The fused lanes return to the runtime directly, so the framework's own error renders fold in
2206
- // static headers here rather than through the shared `wrapResponse` seam.
2207
- const logError = (err, ctx) => {
2208
- if (err instanceof Response)
2209
- return this.wrapWebResponse(err);
2210
- this.logRequestError(err, ctx);
2211
- return this.wrapWebResponse(jsonError(500, "internal_error"));
2212
- };
2216
+ // Same thrown-value contract as the generic lane (`bare-error-lane.ts` via `bareError`); the fused
2217
+ // lane only injects its renderer, so a thrown `status(...)` and the flat 500 fold in static headers
2218
+ // through `wrapWebResponse`. One source of truth for the error surface, no per-lane copy to drift.
2219
+ const logError = (err, ctx) => this.bareError(err, ctx, (result, set) => this.wrapWebResponse(toResponse(result, set)), this.wrapWebResponse);
2213
2220
  if (contextless && decorations === undefined) {
2214
2221
  // `() => ...` can't observe the context - skip allocating one entirely (errors still build
2215
2222
  // one for the structured log, exactly like runContextlessBare).
@@ -2255,7 +2262,7 @@ export class Server {
2255
2262
  * bounded parser remains shared with the generic lane; only the route-invariant entry lookups and
2256
2263
  * lifecycle dispatch disappear from the common synchronous-validator/synchronous-handler case. */
2257
2264
  buildFusedBodyRunner(handler, bodySchema, decorations, maxBodyBytes) {
2258
- const logError = (err, ctx, wrapResponse) => this.bareError(err, ctx, wrapResponse);
2265
+ const logError = (err, ctx, finalize, wrapResponse) => this.bareError(err, ctx, finalize, wrapResponse);
2259
2266
  const runHandler = (ctx, finalize, wrapResponse) => {
2260
2267
  if (decorations !== undefined)
2261
2268
  Object.assign(ctx, decorations);
@@ -2264,7 +2271,7 @@ export class Server {
2264
2271
  output = handler(ctx);
2265
2272
  }
2266
2273
  catch (err) {
2267
- return logError(err, ctx, wrapResponse);
2274
+ return logError(err, ctx, finalize, wrapResponse);
2268
2275
  }
2269
2276
  if (output instanceof Promise) {
2270
2277
  return output.then((result) => {
@@ -2272,20 +2279,20 @@ export class Server {
2272
2279
  return finalize(result, responseSet(ctx), ctx);
2273
2280
  }
2274
2281
  catch (err) {
2275
- return logError(err, ctx, wrapResponse);
2282
+ return logError(err, ctx, finalize, wrapResponse);
2276
2283
  }
2277
- }, (err) => logError(err, ctx, wrapResponse));
2284
+ }, (err) => logError(err, ctx, finalize, wrapResponse));
2278
2285
  }
2279
2286
  try {
2280
2287
  return finalize(output, responseSet(ctx), ctx);
2281
2288
  }
2282
2289
  catch (err) {
2283
- return logError(err, ctx, wrapResponse);
2290
+ return logError(err, ctx, finalize, wrapResponse);
2284
2291
  }
2285
2292
  };
2286
2293
  const runValidated = (result, ctx, finalize, wrapResponse) => {
2287
2294
  if (result.issues !== undefined)
2288
- return wrapResponse(validationError(result.issues));
2295
+ return wrapResponse(plainValidationError(result.issues));
2289
2296
  ctx.body = result.value;
2290
2297
  return runHandler(ctx, finalize, wrapResponse);
2291
2298
  };
@@ -2295,7 +2302,7 @@ export class Server {
2295
2302
  validation = bodySchema["~standard"].validate(parsed);
2296
2303
  }
2297
2304
  catch (err) {
2298
- return logError(err, ctx, wrapResponse);
2305
+ return logError(err, ctx, finalize, wrapResponse);
2299
2306
  }
2300
2307
  if (validation instanceof Promise) {
2301
2308
  return validation.then((settled) => {
@@ -2303,15 +2310,15 @@ export class Server {
2303
2310
  return runValidated(settled, ctx, finalize, wrapResponse);
2304
2311
  }
2305
2312
  catch (err) {
2306
- return logError(err, ctx, wrapResponse);
2313
+ return logError(err, ctx, finalize, wrapResponse);
2307
2314
  }
2308
- }, (err) => logError(err, ctx, wrapResponse));
2315
+ }, (err) => logError(err, ctx, finalize, wrapResponse));
2309
2316
  }
2310
2317
  try {
2311
2318
  return runValidated(validation, ctx, finalize, wrapResponse);
2312
2319
  }
2313
2320
  catch (err) {
2314
- return logError(err, ctx, wrapResponse);
2321
+ return logError(err, ctx, finalize, wrapResponse);
2315
2322
  }
2316
2323
  };
2317
2324
  return (source, params, search, signal, budget, platform, nativeContext, finalize, wrapResponse) => {
@@ -2319,7 +2326,7 @@ export class Server {
2319
2326
  ? RequestContext.native(source, params, search, maxBodyBytes, platform, this.protoPoisoning)
2320
2327
  : new RequestContext(source, params, search, signal, budget, platform, maxBodyBytes, this.protoPoisoning);
2321
2328
  const finish = (value) => runParsed(value, ctx, finalize, wrapResponse);
2322
- return this.readBodyInput(source, maxBodyBytes, finish, wrapResponse, (err) => logError(err, ctx, wrapResponse));
2329
+ return readBodyFramed(source, maxBodyBytes, this.protoPoisoning, finish, wrapResponse, (err) => logError(err, ctx, finalize, wrapResponse));
2323
2330
  };
2324
2331
  }
2325
2332
  /** Web adapter wrapper for the shared body runner. Node passes its native finalizer directly through
@@ -2335,14 +2342,10 @@ export class Server {
2335
2342
  * `Response` passes through, anything else logs and returns a flat 500, and an async validator
2336
2343
  * falls to a then-chain with the same steps. */
2337
2344
  buildFusedQueryWeb(handler, decorations, querySchema, maxBodyBytes) {
2338
- // The fused lanes return to the runtime directly, so the framework's own error renders fold in
2339
- // static headers here rather than through the shared `wrapResponse` seam.
2340
- const logError = (err, ctx) => {
2341
- if (err instanceof Response)
2342
- return this.wrapWebResponse(err);
2343
- this.logRequestError(err, ctx);
2344
- return this.wrapWebResponse(jsonError(500, "internal_error"));
2345
- };
2345
+ // Same thrown-value contract as the generic lane (`bare-error-lane.ts` via `bareError`); the fused
2346
+ // lane only injects its renderer, so a thrown `status(...)` and the flat 500 fold in static headers
2347
+ // through `wrapWebResponse`. One source of truth for the error surface, no per-lane copy to drift.
2348
+ const logError = (err, ctx) => this.bareError(err, ctx, (result, set) => this.wrapWebResponse(toResponse(result, set)), this.wrapWebResponse);
2346
2349
  const runHandler = (ctx, value) => {
2347
2350
  ctx.query = value;
2348
2351
  let result;
@@ -2372,19 +2375,19 @@ export class Server {
2372
2375
  }
2373
2376
  if (validation instanceof Promise) {
2374
2377
  return validation.then((settled) => settled.issues !== undefined
2375
- ? this.wrapWebResponse(validationError(settled.issues))
2378
+ ? this.wrapWebResponse(plainValidationError(settled.issues))
2376
2379
  : runHandler(ctx, settled.value), (err) => logError(err, ctx));
2377
2380
  }
2378
2381
  if (validation.issues !== undefined)
2379
- return this.wrapWebResponse(validationError(validation.issues));
2382
+ return this.wrapWebResponse(plainValidationError(validation.issues));
2380
2383
  return runHandler(ctx, validation.value);
2381
2384
  };
2382
2385
  }
2383
- bareError(err, ctx, wrapResponse) {
2384
- if (err instanceof Response)
2385
- return wrapResponse(err);
2386
- this.logRequestError(err, ctx);
2387
- return wrapResponse(jsonError(500, "internal_error"));
2386
+ bareError(err, ctx, finalize, wrapResponse) {
2387
+ // The thrown-value contract lives in `bare-error-lane.ts`, one source of truth for both this
2388
+ // lane and the fused body runner. `responseSet` and the logger are injected because they reach
2389
+ // into class state; the closure is allocated only on the throw path, never on the hot lane.
2390
+ return renderBareError(err, ctx, finalize, wrapResponse, responseSet, (e, c) => this.logRequestError(e, c));
2388
2391
  }
2389
2392
  // @ts-expect-error TS6133 -- invoked structurally by compiled plans (internal/route-execution.ts)
2390
2393
  // biome-ignore lint/correctness/noUnusedPrivateClassMembers: invoked structurally by compiled plans (internal/route-execution.ts)
@@ -2418,27 +2421,7 @@ export class Server {
2418
2421
  // @ts-expect-error TS6133 -- invoked structurally by compiled plans (internal/route-execution.ts)
2419
2422
  // biome-ignore lint/correctness/noUnusedPrivateClassMembers: invoked structurally by compiled plans (internal/route-execution.ts)
2420
2423
  runBodyOnly(entry, source, ctx, finalize, wrapResponse) {
2421
- 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));
2422
- }
2423
- /** Shared bounded body framing/parser. Both body lanes use the same trust-boundary checks; only
2424
- * the validation + handler continuation differs. */
2425
- readBodyInput(source, maxBodyBytes, onParsed, wrapResponse, onError) {
2426
- const contentType = headerOf(source, "content-type") ?? "";
2427
- if (contentType !== "application/json" && !contentType.includes("application/json")) {
2428
- if (isUrlEncodedForm(contentType)) {
2429
- return readBoundedForm(source, maxBodyBytes).then((form) => (form instanceof Response ? wrapResponse(form) : onParsed(form)), onError);
2430
- }
2431
- return Promise.resolve(wrapResponse(jsonError(415, "unsupported_media_type")));
2432
- }
2433
- // All JSON bodies go through readBoundedJson - the single enforcement point for the framed
2434
- // fast path (post-read byte re-check), the streaming cap, and the prototype-poisoning guard.
2435
- // A separate inlined native-json() fast path here would fork the trust boundary in two.
2436
- try {
2437
- return this.readBoundedJson(source, maxBodyBytes, (parsed) => (parsed instanceof Response ? wrapResponse(parsed) : onParsed(parsed)), onError);
2438
- }
2439
- catch (err) {
2440
- return Promise.resolve(onError(err));
2441
- }
2424
+ return readBodyFramed(source, entry.bodyLimit ?? UNLIMITED_BODY_BYTES, this.protoPoisoning, (parsed) => this.finishBodyOnly(entry, parsed, ctx, finalize, wrapResponse), wrapResponse, (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse));
2442
2425
  }
2443
2426
  /** Validate + run the handler for the bodyOnly path - shared by the inline fast path and the
2444
2427
  * streaming fallback. A method (not per-request closures) so the hot path allocates nothing
@@ -2493,13 +2476,13 @@ export class Server {
2493
2476
  if (validation instanceof Promise) {
2494
2477
  return validation.then((settled) => {
2495
2478
  if (settled.issues !== undefined)
2496
- return wrapResponse(validationError(settled.issues));
2479
+ return wrapResponse(plainValidationError(settled.issues));
2497
2480
  ctx.body = settled.value;
2498
2481
  return this.executeHandler(entry, ctx, finalize);
2499
2482
  });
2500
2483
  }
2501
2484
  if (validation.issues !== undefined)
2502
- return wrapResponse(validationError(validation.issues));
2485
+ return wrapResponse(plainValidationError(validation.issues));
2503
2486
  ctx.body = validation.value;
2504
2487
  return this.executeHandler(entry, ctx, finalize);
2505
2488
  }
@@ -2508,18 +2491,18 @@ export class Server {
2508
2491
  if (validation instanceof Promise) {
2509
2492
  return validation.then((settled) => {
2510
2493
  if (settled.issues !== undefined)
2511
- return wrapResponse(validationError(settled.issues));
2494
+ return wrapResponse(plainValidationError(settled.issues));
2512
2495
  ctx.query = settled.value;
2513
2496
  return this.executeHandler(entry, ctx, finalize, wrapResponse);
2514
2497
  }, (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse));
2515
2498
  }
2516
2499
  if (validation.issues !== undefined)
2517
- return wrapResponse(validationError(validation.issues));
2500
+ return wrapResponse(plainValidationError(validation.issues));
2518
2501
  ctx.query = validation.value;
2519
2502
  return this.executeHandler(entry, ctx, finalize, wrapResponse);
2520
2503
  }
2521
2504
  }
2522
- return wrapResponse(validationError(originalIssues));
2505
+ return wrapResponse(plainValidationError(originalIssues));
2523
2506
  }
2524
2507
  applyBodyValidation(entry, result, ctx, finalize, wrapResponse) {
2525
2508
  if (result.issues !== undefined) {
@@ -2531,7 +2514,7 @@ export class Server {
2531
2514
  }
2532
2515
  return this.handleValidationErrorRecovery(entry, recovery, ctx, finalize, wrapResponse, result.issues, "body");
2533
2516
  }
2534
- return wrapResponse(validationError(result.issues));
2517
+ return wrapResponse(plainValidationError(result.issues));
2535
2518
  }
2536
2519
  ctx.body = result.value;
2537
2520
  return this.executeHandler(entry, ctx, finalize);
@@ -2574,7 +2557,7 @@ export class Server {
2574
2557
  }
2575
2558
  return this.handleValidationErrorRecovery(entry, recovery, ctx, finalize, wrapResponse, result.issues, "query");
2576
2559
  }
2577
- return wrapResponse(validationError(result.issues));
2560
+ return wrapResponse(plainValidationError(result.issues));
2578
2561
  }
2579
2562
  ctx.query = result.value;
2580
2563
  return this.executeHandler(entry, ctx, finalize, wrapResponse);
@@ -2869,6 +2852,8 @@ export class Server {
2869
2852
  if (extension instanceof Promise) {
2870
2853
  return this.continueLifecycleAfterDerive(entry, ctx, finalize, wrapResponse, i, extension);
2871
2854
  }
2855
+ if (isDeriveEarlyExit(extension))
2856
+ return finalize(extension, responseSet(ctx));
2872
2857
  Object.assign(ctx, extension);
2873
2858
  }
2874
2859
  for (let i = 0; i < entry.beforeHandle.length; i++) {
@@ -2896,6 +2881,8 @@ export class Server {
2896
2881
  if (extension instanceof Promise) {
2897
2882
  return this.continueLifecycleAfterDerive(entry, ctx, finalize, wrapResponse, 0, extension);
2898
2883
  }
2884
+ if (isDeriveEarlyExit(extension))
2885
+ return finalize(extension, responseSet(ctx));
2899
2886
  Object.assign(ctx, extension);
2900
2887
  const outcome = entry.beforeHandle[0](ctx);
2901
2888
  if (outcome instanceof Promise) {
@@ -2924,10 +2911,16 @@ export class Server {
2924
2911
  }
2925
2912
  async continueLifecycleAfterDerive(entry, ctx, finalize, wrapResponse, firstIndex, first) {
2926
2913
  try {
2927
- Object.assign(ctx, await first);
2914
+ const settled = await first;
2915
+ if (isDeriveEarlyExit(settled))
2916
+ return finalize(settled, responseSet(ctx));
2917
+ Object.assign(ctx, settled);
2928
2918
  for (let i = firstIndex + 1; i < entry.derives.length; i++) {
2929
- const extension = entry.derives[i](ctx);
2930
- Object.assign(ctx, extension instanceof Promise ? await extension : extension);
2919
+ const raw = entry.derives[i](ctx);
2920
+ const extension = raw instanceof Promise ? await raw : raw;
2921
+ if (isDeriveEarlyExit(extension))
2922
+ return finalize(extension, responseSet(ctx));
2923
+ Object.assign(ctx, extension);
2931
2924
  }
2932
2925
  return await this.runLifecycleHooksAsync(entry, ctx, finalize, wrapResponse);
2933
2926
  }
@@ -3031,7 +3024,7 @@ export class Server {
3031
3024
  path: pathnameOf(ctx.req.url),
3032
3025
  detail: outcome.message,
3033
3026
  });
3034
- return wrapResponse(jsonError(500, "internal_error"));
3027
+ return wrapResponse(plainError(500, "internal_error"));
3035
3028
  }
3036
3029
  if (outcome.kind === "warn") {
3037
3030
  this.logger.warn("response contract", {
@@ -3042,14 +3035,33 @@ export class Server {
3042
3035
  }
3043
3036
  return finalize(outcome.value, responseSet(ctx));
3044
3037
  }
3045
- async handleLifecycleError(entry, err, ctx, finalize, wrapResponse) {
3038
+ /** Synchronous by design, and `MaybePromise` rather than `Promise` for the same reason: control flow
3039
+ * out of a lifecycle stage must not cost a promise. An `async` method allocates one and defers the
3040
+ * result a microtask even when every branch it takes returns immediately, which is the common case
3041
+ * here - a thrown `status(...)`/`Response`, or a route with no `onError` hook at all. Only the hook
3042
+ * loop, which may await, drops into {@link runErrorHooks}. */
3043
+ handleLifecycleError(entry, err, ctx, finalize, wrapResponse) {
3046
3044
  // A *thrown* Response is deliberate control flow, not an error - a guard throws a redirect/401,
3047
3045
  // an action throws an error page. Return it as-is (Remix/SvelteKit semantics); don't run onError
3048
3046
  // or log it as a 500. This is what makes `throw redirect(...)` / `requireSession(...)` work from
3049
3047
  // any handler or loader.
3050
3048
  if (err instanceof Response)
3051
3049
  return wrapResponse(err);
3052
- // onError hooks may return a custom response; otherwise the default 500 stands.
3050
+ // Same rule for a thrown `status(...)`, but through `finalize` rather than `wrapResponse`: the
3051
+ // value is still plain data, so the ordinary JSON lane renders it and no `Response` is built.
3052
+ if (isResponseResult(err))
3053
+ return finalize(err, responseSet(ctx));
3054
+ if (entry.onError.length === 0) {
3055
+ // Never crash the server or leak internals. The client gets a flat 500; the detail goes to the
3056
+ // (redacting) logger. Body-read failures and around-hook failures land here too.
3057
+ this.logRequestError(err, ctx);
3058
+ return wrapResponse(plainError(500, "internal_error"));
3059
+ }
3060
+ return this.runErrorHooks(entry, err, ctx, finalize, wrapResponse);
3061
+ }
3062
+ /** The `onError` chain, split off {@link handleLifecycleError} so its `await` costs only the routes
3063
+ * that registered a hook. A hook may return a custom response; otherwise the default 500 stands. */
3064
+ async runErrorHooks(entry, err, ctx, finalize, wrapResponse) {
3053
3065
  // Indexed for the same reason as the `beforeHandle` chain above: the iterator would outlive
3054
3066
  // the `await` and stay heap-allocated on both engines.
3055
3067
  for (let i = 0; i < entry.onError.length; i++) {
@@ -3058,49 +3070,33 @@ export class Server {
3058
3070
  if (handled !== undefined)
3059
3071
  return finalize(handled, responseSet(ctx));
3060
3072
  }
3061
- // Never crash the server or leak internals. The client gets a flat 500; the detail goes to the
3062
- // (redacting) logger. Body-read failures and around-hook failures land here too.
3063
3073
  this.logRequestError(err, ctx);
3064
- return wrapResponse(jsonError(500, "internal_error"));
3074
+ return wrapResponse(plainError(500, "internal_error"));
3065
3075
  }
3066
3076
  /** Log an unhandled request error to the (redacting) logger - shared by {@link runLifecycle} and the
3067
3077
  * bare fast path ({@link bareError}) so both record the same fields. Never throws; never leaks. */
3068
3078
  logRequestError(err, ctx) {
3069
- // An error's own text can quote the input that produced it, so how much of it reaches the sink is
3070
- // the app's call (`errorLogDetail`). The default keeps it: a 500 with no message and no stack is
3071
- // an incident nobody can diagnose, and the framework already ships the narrower instrument for
3072
- // the leak - a redacting logger with `valuePatterns`. `"none"` is there for an untrusted sink.
3073
- const detail = this.errorLogDetail;
3074
- this.logger.error("unhandled request error", {
3075
- method: ctx.req.method,
3076
- path: pathnameOf(ctx.req.url),
3077
- name: err instanceof Error ? err.name : "Error",
3078
- // `detail`, not `message`: the logger uses `message` for its own first argument, so a field of
3079
- // that name is silently overwritten and the thrown error's own text never reaches the sink. It
3080
- // survived only incidentally inside `stack`, and was lost outright for a non-Error throw.
3081
- ...(detail === "none" ? {} : { detail: err instanceof Error ? err.message : String(err) }),
3082
- ...(detail === "full" && err instanceof Error ? { stack: err.stack } : {}),
3083
- });
3079
+ emitRequestErrorLog(this.logger, this.errorLogDetail, err, ctx);
3084
3080
  }
3085
3081
  async readAndValidateBody(req, entry, ctx) {
3086
3082
  const contentType = headerOf(req, "content-type") ?? "";
3087
3083
  let parsed;
3088
3084
  if (contentType === "application/json" || contentType.includes("application/json")) {
3089
- const json = await this.readBoundedJson(req, entry.bodyLimit ?? this.maxBodyBytes);
3090
- if (json instanceof Response)
3085
+ const json = await this.readBoundedJson(req, entry.bodyLimit ?? UNLIMITED_BODY_BYTES);
3086
+ if (isResponseResult(json))
3091
3087
  return json;
3092
3088
  parsed = json;
3093
3089
  }
3094
3090
  else if (isUrlEncodedForm(contentType)) {
3095
- const form = await readBoundedForm(req, entry.bodyLimit ?? this.maxBodyBytes);
3096
- if (form instanceof Response)
3091
+ const form = await readBoundedForm(req, entry.bodyLimit ?? UNLIMITED_BODY_BYTES);
3092
+ if (isResponseResult(form))
3097
3093
  return form;
3098
3094
  parsed = form;
3099
3095
  }
3100
3096
  else {
3101
3097
  // multipart/form-data (file uploads) stays 415 on the schema path by design - files don't
3102
3098
  // fit a value schema; use a schema-less route + @nifrajs/uploads helpers for those.
3103
- return jsonError(415, "unsupported_media_type");
3099
+ return plainError(415, "unsupported_media_type");
3104
3100
  }
3105
3101
  const validation = entry.schema.body["~standard"].validate(parsed);
3106
3102
  const result = validation instanceof Promise ? await validation : validation;
@@ -3125,7 +3121,7 @@ export class Server {
3125
3121
  }
3126
3122
  const hook = entry.schema?.onValidationError ?? this.defaultOnValidationError;
3127
3123
  if (hook === undefined)
3128
- return validationError(result.issues);
3124
+ return plainValidationError(result.issues);
3129
3125
  const attempted = hook(result.issues, ctx, kind);
3130
3126
  if (attempted instanceof Promise) {
3131
3127
  return attempted.then((recovery) => this.finishLifecycleValidationRecovery(entry, kind, result.issues, recovery, assign));
@@ -3134,7 +3130,7 @@ export class Server {
3134
3130
  }
3135
3131
  finishLifecycleValidationRecovery(entry, kind, issues, recovery, assign) {
3136
3132
  if (recovery === undefined)
3137
- return validationError(issues);
3133
+ return plainValidationError(issues);
3138
3134
  if (recovery instanceof Response)
3139
3135
  return recovery;
3140
3136
  const schema = kind === "body"
@@ -3148,13 +3144,13 @@ export class Server {
3148
3144
  if (retried instanceof Promise) {
3149
3145
  return retried.then((settled) => {
3150
3146
  if (settled.issues !== undefined)
3151
- return validationError(settled.issues);
3147
+ return plainValidationError(settled.issues);
3152
3148
  assign(settled.value);
3153
3149
  return undefined;
3154
3150
  });
3155
3151
  }
3156
3152
  if (retried.issues !== undefined)
3157
- return validationError(retried.issues);
3153
+ return plainValidationError(retried.issues);
3158
3154
  assign(retried.value);
3159
3155
  return undefined;
3160
3156
  }