@nifrajs/core 2.8.1 → 2.9.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 (88) hide show
  1. package/dist/index.d.ts +1 -1
  2. package/dist/index.js +1 -1
  3. package/dist/internal/route-execution.d.ts +8 -2
  4. package/dist/internal/route-execution.d.ts.map +1 -1
  5. package/dist/node-direct.d.ts.map +1 -1
  6. package/dist/node-direct.js +3 -1
  7. package/dist/node-direct.js.map +1 -1
  8. package/dist/router/router.d.ts.map +1 -1
  9. package/dist/router/router.js +9 -1
  10. package/dist/router/router.js.map +1 -1
  11. package/dist/server/body.d.ts +3 -0
  12. package/dist/server/body.d.ts.map +1 -1
  13. package/dist/server/body.js +9 -0
  14. package/dist/server/body.js.map +1 -1
  15. package/dist/server/context.d.ts +2 -0
  16. package/dist/server/context.d.ts.map +1 -1
  17. package/dist/server/cookies.d.ts.map +1 -1
  18. package/dist/server/cookies.js +4 -1
  19. package/dist/server/cookies.js.map +1 -1
  20. package/dist/server/http.d.ts.map +1 -1
  21. package/dist/server/http.js +16 -3
  22. package/dist/server/http.js.map +1 -1
  23. package/dist/server/node-outcome-hook.d.ts +78 -1
  24. package/dist/server/node-outcome-hook.d.ts.map +1 -1
  25. package/dist/server/node-outcome-hook.js +197 -1
  26. package/dist/server/node-outcome-hook.js.map +1 -1
  27. package/dist/server/node-outcome.d.ts +22 -2
  28. package/dist/server/node-outcome.d.ts.map +1 -1
  29. package/dist/server/node-outcome.js +86 -11
  30. package/dist/server/node-outcome.js.map +1 -1
  31. package/dist/server/query.d.ts +2 -0
  32. package/dist/server/query.d.ts.map +1 -1
  33. package/dist/server/query.js +27 -1
  34. package/dist/server/query.js.map +1 -1
  35. package/dist/server/request-context.d.ts +8 -1
  36. package/dist/server/request-context.d.ts.map +1 -1
  37. package/dist/server/request-context.js +23 -2
  38. package/dist/server/request-context.js.map +1 -1
  39. package/dist/server/respond.d.ts +29 -3
  40. package/dist/server/respond.d.ts.map +1 -1
  41. package/dist/server/respond.js +279 -16
  42. package/dist/server/respond.js.map +1 -1
  43. package/dist/server/response-contract-lane.d.ts +17 -3
  44. package/dist/server/response-contract-lane.d.ts.map +1 -1
  45. package/dist/server/response-contract-lane.js +6 -3
  46. package/dist/server/response-contract-lane.js.map +1 -1
  47. package/dist/server/server-types.d.ts +37 -0
  48. package/dist/server/server-types.d.ts.map +1 -1
  49. package/dist/server/server.d.ts +151 -7
  50. package/dist/server/server.d.ts.map +1 -1
  51. package/dist/server/server.js +1158 -176
  52. package/dist/server/server.js.map +1 -1
  53. package/dist/server/static-headers.d.ts +47 -0
  54. package/dist/server/static-headers.d.ts.map +1 -0
  55. package/dist/server/static-headers.js +109 -0
  56. package/dist/server/static-headers.js.map +1 -0
  57. package/dist/server/webhook.d.ts.map +1 -1
  58. package/dist/server/webhook.js +11 -2
  59. package/dist/server/webhook.js.map +1 -1
  60. package/dist/server.d.ts +2 -1
  61. package/dist/server.d.ts.map +1 -1
  62. package/dist/server.js +1 -0
  63. package/dist/server.js.map +1 -1
  64. package/dist/transport-codec.d.ts +15 -0
  65. package/dist/transport-codec.d.ts.map +1 -1
  66. package/dist/transport-codec.js +49 -10
  67. package/dist/transport-codec.js.map +1 -1
  68. package/package.json +1 -1
  69. package/src/index.ts +1 -1
  70. package/src/internal/route-execution.ts +19 -1
  71. package/src/node-direct.ts +7 -1
  72. package/src/router/router.ts +8 -1
  73. package/src/server/body.ts +10 -0
  74. package/src/server/context.ts +2 -0
  75. package/src/server/cookies.ts +4 -1
  76. package/src/server/http.ts +16 -3
  77. package/src/server/node-outcome-hook.ts +315 -1
  78. package/src/server/node-outcome.ts +100 -14
  79. package/src/server/query.ts +26 -1
  80. package/src/server/request-context.ts +23 -1
  81. package/src/server/respond.ts +386 -17
  82. package/src/server/response-contract-lane.ts +17 -3
  83. package/src/server/server-types.ts +42 -0
  84. package/src/server/server.ts +1615 -239
  85. package/src/server/static-headers.ts +142 -0
  86. package/src/server/webhook.ts +13 -2
  87. package/src/server.ts +9 -0
  88. package/src/transport-codec.ts +55 -10
@@ -6,13 +6,153 @@ import { RouteCatalog } from "../internal/route-catalog.js";
6
6
  import { isSameOriginRequest } from "../internal/same-origin.js";
7
7
  import { compileRoutePattern, decodeRouteParams } from "../router/pattern.js";
8
8
  import { EMPTY_PARAMS, Router } from "../router/router.js";
9
- import { parseContentLength } from "./body.js";
9
+ import { assertByteLimit, parseContentLength } from "./body.js";
10
10
  import { resolveClientIp } from "./client-ip.js";
11
11
  import { jsonError, pathnameOf, urlPartsOf } from "./http.js";
12
+ import { withStaticNodeHeaders } from "./node-outcome.js";
13
+ import { recordHeadersView, } from "./node-outcome-hook.js";
12
14
  import { isUrlEncodedForm, queryObjectOf, readBoundedForm, searchOf, } from "./query.js";
13
15
  import { RequestContext, readBoundedJsonSource } from "./request-context.js";
14
- import { fusedRespond, fusedRespondNoSet, toResponse } from "./respond.js";
16
+ import { applyStaticResponseHeaders, buildStaticResponseHeaders, fusedRespond, fusedRespondNoSet, knownMutableHeaders, markTaggedResponse, rememberMutableHeaders, taggedResponseBody, taggedResponseOwner, toResponse, } from "./respond.js";
15
17
  import { CONTEXT_SEARCH, CONTEXT_SET, EMPTY_RESPONSE_CONTROLS, getNeverAbortSignal, getUnboundedRequestBudget, headerOf, requestOf, } from "./runtime-core.js";
18
+ 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
+ const HEADER_MUTABILITY_PROBE = "x-nifra-header-probe";
70
+ const GUARDED_RESPONSE_HEADERS = new WeakSet();
71
+ /**
72
+ * Detect a guarded Web Headers object before invoking user code. Retrying after catching any
73
+ * TypeError is observably wrong: user hooks are allowed to throw TypeError themselves, and a retry
74
+ * runs those hooks twice. The hot path never reaches the probe: every framework-constructed
75
+ * Response stamps its headers as known-mutable at construction (see respond.ts), so only a
76
+ * handler-returned foreign `Response` - the case that can actually be guarded - pays it, once per
77
+ * headers object. The probe uses a valid private header name and restores a pre-existing value, so
78
+ * the fallback is limited to the actual immutable/guarded-header case.
79
+ */
80
+ function hasMutableResponseHeaders(headers) {
81
+ if (knownMutableHeaders(headers))
82
+ return true;
83
+ if (GUARDED_RESPONSE_HEADERS.has(headers))
84
+ return false;
85
+ let previous = null;
86
+ try {
87
+ previous = headers.get(HEADER_MUTABILITY_PROBE);
88
+ headers.set(HEADER_MUTABILITY_PROBE, "1");
89
+ if (previous === null)
90
+ headers.delete(HEADER_MUTABILITY_PROBE);
91
+ else
92
+ headers.set(HEADER_MUTABILITY_PROBE, previous);
93
+ rememberMutableHeaders(headers);
94
+ return true;
95
+ }
96
+ catch {
97
+ try {
98
+ if (previous === null)
99
+ headers.delete(HEADER_MUTABILITY_PROBE);
100
+ else
101
+ headers.set(HEADER_MUTABILITY_PROBE, previous);
102
+ }
103
+ catch {
104
+ // The guarded object rejected the cleanup too; the clone below is authoritative.
105
+ }
106
+ GUARDED_RESPONSE_HEADERS.add(headers);
107
+ return false;
108
+ }
109
+ }
110
+ /** Adapt a portable {@link ResponseBodyHook} into the Web `onResponse` walk. Only a Response
111
+ * carrying the framework-buffered body tag participates - a raw or streamed Response is skipped by
112
+ * contract, never drained. */
113
+ function webResponseBodyHook(fn, owners) {
114
+ return (response, req) => {
115
+ const body = taggedResponseBody(response, owners);
116
+ if (body === undefined)
117
+ return response;
118
+ const out = fn(body, response.headers, webRequestView(req), response.status);
119
+ if (out instanceof Promise)
120
+ return out.then((replaced) => withReplacedBody(response, replaced));
121
+ return withReplacedBody(response, out);
122
+ };
123
+ }
124
+ /** Minimal request view over a Web `Request` for portable header hooks on the Web serving paths. */
125
+ function webRequestView(req) {
126
+ return { method: req.method, url: req.url, header: (name) => req.headers.get(name) };
127
+ }
128
+ /**
129
+ * Adapt a portable {@link ResponseHeadersHook} into the Web `onResponse` walk: run it against the
130
+ * response's own `Headers` in place (no clone). A response whose headers are GUARDED (a raw
131
+ * `fetch()`ed Response returned by a handler) throws on the FIRST mutation, so nothing was applied
132
+ * yet - rerun once against a mutable copy. Async hooks should perform their mutations before their
133
+ * first await for the guard fallback to cover them.
134
+ */
135
+ function webResponseHeadersHook(fn) {
136
+ return (response, req) => {
137
+ const view = webRequestView(req);
138
+ if (!hasMutableResponseHeaders(response.headers)) {
139
+ const clone = new Response(response.body, response);
140
+ const out = fn(clone.headers, view, clone.status);
141
+ return out instanceof Promise ? out.then(() => clone) : clone;
142
+ }
143
+ const out = fn(response.headers, view, response.status);
144
+ return out instanceof Promise ? out.then(() => response) : response;
145
+ };
146
+ }
147
+ /** Adapt a raw-response fallback hook. Tagged framework payloads already ran through the body tier,
148
+ * while untagged Responses include streams, proxied fetches, and framework-generated error responses. */
149
+ function webResponseRawHook(fn, owners) {
150
+ return (response, req) => {
151
+ if (taggedResponseBody(response, owners) !== undefined)
152
+ return response;
153
+ return fn(response, req);
154
+ };
155
+ }
16
156
  import { INSTALL_EFFECT_LEDGER, INSTALL_IDEMPOTENCY, INSTALL_MCP, INSTALL_NODE_DIRECT, INSTALL_RESPONSE_CONTRACT, INSTALL_SSE, INSTALL_WS, } from "./install.js";
17
157
  import { jsonLogger } from "./logger.js";
18
158
  const functionToString = Function.prototype.toString;
@@ -163,9 +303,34 @@ export class Server {
163
303
  onErrorHooks;
164
304
  aroundHooks;
165
305
  onRequestHooks;
306
+ onNodeRequestHooks;
307
+ /** Registration-time eligibility for the Node request twin lane. */
308
+ nodeRequestHooksComplete;
166
309
  onResponseHooks;
310
+ onNodeResponseHooks;
311
+ /** Registration-time eligibility for the Node response twin lane. */
312
+ nodeResponseHooksComplete;
167
313
  onResponseFinalizedHooks;
314
+ /**
315
+ * Statically declared response headers, merged and prebuilt at registration; `undefined` (the
316
+ * default) leaves every render path in its original shape. These are NOT response hooks - they are
317
+ * folded into response construction - so declaring them keeps the fused native lanes an
318
+ * `onResponse` hook would have disabled.
319
+ */
320
+ staticResponseHeaders;
321
+ /** `wrapResponse` for the Web lanes: identity until static headers exist to fold into the
322
+ * framework's own error/404/timeout renders, which are built outside the header init. */
323
+ wrapWebResponse;
324
+ webResponseTimeout;
325
+ /** Body/raw response hooks need a framework-payload marker; keep that decision per app. */
326
+ responseBodyTag;
327
+ responseBodyOwners;
328
+ finalizeResponse = (result, set) => toResponse(result, set, this.responseBodyTag, this.staticResponseHeaders);
168
329
  responseRequests;
330
+ /** Original source → request observed by generic request hooks, including in-place mutations. */
331
+ responseSources;
332
+ /** Memoized NodeRequestContext per plain-`Request` source - see {@link nodeRequestContextOf}. */
333
+ nodeContexts;
169
334
  /** Names of plugins/middleware already applied via `use` - for idempotent dedupe. */
170
335
  appliedPlugins;
171
336
  /** Order-scoped evidence captured by routes registered after an assured plugin. */
@@ -180,8 +345,12 @@ export class Server {
180
345
  this.wsRouter = new Router();
181
346
  this.wsRouteCount = 0;
182
347
  this.topics = undefined;
183
- this.maxBodyBytes = options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES;
184
- this.wsMaxPayloadBytes = options.wsMaxPayloadBytes ?? this.maxBodyBytes;
348
+ const maxBodyBytes = options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES;
349
+ assertByteLimit(maxBodyBytes, "maxBodyBytes");
350
+ const wsMaxPayloadBytes = options.wsMaxPayloadBytes ?? maxBodyBytes;
351
+ assertByteLimit(wsMaxPayloadBytes, "wsMaxPayloadBytes");
352
+ this.maxBodyBytes = maxBodyBytes;
353
+ this.wsMaxPayloadBytes = wsMaxPayloadBytes;
185
354
  this.requestTimeoutMs = options.requestTimeoutMs ?? 0;
186
355
  this.clientIpTrust = options.clientIp;
187
356
  this.acceptInboundDeadlines = options.acceptInboundDeadlines ?? false;
@@ -219,9 +388,20 @@ export class Server {
219
388
  this.onErrorHooks = [];
220
389
  this.aroundHooks = [];
221
390
  this.onRequestHooks = [];
391
+ this.onNodeRequestHooks = [];
392
+ this.nodeRequestHooksComplete = true;
222
393
  this.onResponseHooks = [];
394
+ this.onNodeResponseHooks = [];
395
+ this.nodeResponseHooksComplete = true;
223
396
  this.onResponseFinalizedHooks = [];
397
+ this.staticResponseHeaders = undefined;
398
+ this.wrapWebResponse = IDENTITY_RESPONSE;
399
+ this.webResponseTimeout = RESPONSE_TIMEOUT;
400
+ this.responseBodyTag = undefined;
401
+ this.responseBodyOwners = new Set();
224
402
  this.responseRequests = new WeakMap();
403
+ this.responseSources = new WeakMap();
404
+ this.nodeContexts = new WeakMap();
225
405
  this.appliedPlugins = new Set();
226
406
  this.activeAssurance = [];
227
407
  this.globalAssurance = [];
@@ -252,6 +432,8 @@ export class Server {
252
432
  onRequest(fn) {
253
433
  this.assertConfigurable("onRequest()");
254
434
  this.onRequestHooks.push(fn);
435
+ this.onNodeRequestHooks.push(undefined);
436
+ this.nodeRequestHooksComplete = false;
255
437
  return this;
256
438
  }
257
439
  /** Run after validation, before the handler; a non-`undefined` return short-circuits. Order-scoped. */
@@ -313,6 +495,115 @@ export class Server {
313
495
  onResponse(fn) {
314
496
  this.assertConfigurable("onResponse()");
315
497
  this.onResponseHooks.push(fn);
498
+ this.onNodeResponseHooks.push(undefined);
499
+ this.nodeResponseHooksComplete = false;
500
+ return this;
501
+ }
502
+ /**
503
+ * Declare response headers with NO per-request decision behind them - and pay nothing for them on
504
+ * the request path.
505
+ *
506
+ * These are not a hook. Because the values are known at wire-up, they are folded into response
507
+ * construction (one prebuilt init for JSON renders, one record merge where the request set its own
508
+ * headers), so an app whose only response middleware is static keeps every fused and native lane -
509
+ * `onResponse`/`onResponseHeaders` disable Bun's fused native routes and, for a full `onResponse`,
510
+ * the Node direct writer. They apply to EVERY response, exactly as a response hook would: success,
511
+ * error, 404/405, timeout, and short-circuit alike.
512
+ *
513
+ * app.responseHeaders({ "x-frame-options": "DENY", "referrer-policy": "no-referrer" })
514
+ *
515
+ * They are DEFAULTS: a value the request itself produced (`c.set.headers`, or a response hook)
516
+ * wins, whatever casing it used. Names are lowercased once here; a non-string value, an invalid
517
+ * name, `__proto__`, or a name the render owns (`content-type`, `content-length`,
518
+ * `transfer-encoding`, `set-cookie`) throws a `TypeError` at wire-up.
519
+ *
520
+ * ORDERING: declarations made before any response hook fold into one static record. One made AFTER
521
+ * a response hook cannot - the hook may already have written that name and must keep winning - so
522
+ * it registers as an ordinary `onResponseHeaders` hook instead, preserving registration order at
523
+ * the cost of the static tier's speed. Declare static headers first.
524
+ */
525
+ responseHeaders(record) {
526
+ this.assertConfigurable("responseHeaders()");
527
+ return this.addStaticResponseHeaders(normalizeStaticResponseHeaders(record));
528
+ }
529
+ /** Wire an already-validated lowercase record into the static tier, or - once a dynamic response
530
+ * hook owns part of the header state - into a hook that runs in the right order. */
531
+ addStaticResponseHeaders(record) {
532
+ if (this.onResponseHooks.length > 0) {
533
+ return this.onResponseHeaders((headers) => {
534
+ for (const name of Object.keys(record)) {
535
+ if (!headers.has(name))
536
+ headers.set(name, record[name]);
537
+ }
538
+ });
539
+ }
540
+ const merged = this.staticResponseHeaders === undefined
541
+ ? record
542
+ : { ...this.staticResponseHeaders.record, ...record };
543
+ this.staticResponseHeaders = buildStaticResponseHeaders(merged);
544
+ const statics = this.staticResponseHeaders;
545
+ this.wrapWebResponse = (response) => applyStaticResponseHeaders(response, statics);
546
+ this.webResponseTimeout = () => applyStaticResponseHeaders(RESPONSE_TIMEOUT(), statics);
547
+ return this;
548
+ }
549
+ enableResponseBodyTagging() {
550
+ if (this.responseBodyTag === undefined) {
551
+ this.responseBodyTag = Object.freeze({});
552
+ this.responseBodyOwners.add(this.responseBodyTag);
553
+ }
554
+ return this.responseBodyTag;
555
+ }
556
+ /**
557
+ * Register a response transform for raw/streamed Responses. Framework-serialized payloads stay on
558
+ * the body tier; this hook is only entered for untagged Responses (streams, proxied fetches, and
559
+ * framework-generated error responses). A no-op native twin keeps buffered JSON on the direct lane.
560
+ */
561
+ onResponseRaw(fn) {
562
+ this.assertConfigurable("onResponseRaw()");
563
+ this.enableResponseBodyTagging();
564
+ this.onResponseHooks.push(webResponseRawHook(fn, this.responseBodyOwners));
565
+ this.onNodeResponseHooks.push(() => undefined);
566
+ return this;
567
+ }
568
+ /**
569
+ * Register a PORTABLE header-only response hook - one implementation, fast on every runtime.
570
+ *
571
+ * The hook receives a mutable case-insensitive header view, a minimal request view, and the
572
+ * status. On the Web serving paths it runs inside the normal `onResponse` walk against the
573
+ * response's own `Headers` (mutating in place - no clone). On Node it self-pairs as a native
574
+ * response hook against the outcome's plain header record, so registering one NEVER forces the
575
+ * Node adapter off its direct socket writer the way a full `onResponse(res: Response)` hook does.
576
+ * Prefer this over `onResponse` whenever the middleware only reads/writes headers.
577
+ */
578
+ onResponseHeaders(fn) {
579
+ this.assertConfigurable("onResponseHeaders()");
580
+ this.onResponseHooks.push(webResponseHeadersHook(fn));
581
+ this.onNodeResponseHooks.push((response, req) => fn(recordHeadersView(response), req, response.status));
582
+ return this;
583
+ }
584
+ /**
585
+ * Register a PORTABLE post-serialization body hook - the payload tier. The hook receives the
586
+ * FINAL framework-serialized bytes (plus the mutable header view and status) and may return
587
+ * replacement bytes; `undefined` keeps the body. On the Node direct writer the bytes come
588
+ * straight off the outcome record; on the Web serving paths they ride the framework-built
589
+ * Response as a tag, so no body stream is ever drained on any runtime. A handler-returned raw
590
+ * `Response` (proxied fetch, SSE, streamed SSR) is skipped by contract - transforming those is
591
+ * what the full `onResponse` hook is for.
592
+ */
593
+ onResponseBody(fn) {
594
+ this.assertConfigurable("onResponseBody()");
595
+ this.enableResponseBodyTagging();
596
+ this.onResponseHooks.push(webResponseBodyHook(fn, this.responseBodyOwners));
597
+ this.onNodeResponseHooks.push((response, req) => {
598
+ const body = response.body;
599
+ if (body === null)
600
+ return undefined;
601
+ const out = fn(body, recordHeadersView(response), req, response.status);
602
+ if (out instanceof Promise)
603
+ return out.then((replaced) => applyBodyReplacement(response, replaced));
604
+ applyBodyReplacement(response, out);
605
+ return undefined;
606
+ });
316
607
  return this;
317
608
  }
318
609
  /** Observe the terminal response after all transformations. Observers are ordered and fail-open. */
@@ -359,16 +650,42 @@ export class Server {
359
650
  }
360
651
  this.globalAssurance.push(...evidence.filter((item) => item.scope === "global"));
361
652
  this.activeAssurance.push(...evidence.filter((item) => item.scope === "subsequent"));
362
- if (arg.onRequest !== undefined)
363
- this.onRequest(arg.onRequest);
653
+ if (arg.onRequest !== undefined) {
654
+ this.assertConfigurable("onRequest()");
655
+ this.onRequestHooks.push(arg.onRequest);
656
+ this.onNodeRequestHooks.push(arg.onNodeRequest);
657
+ if (arg.onNodeRequest === undefined)
658
+ this.nodeRequestHooksComplete = false;
659
+ }
660
+ else if (arg.onNodeRequest !== undefined) {
661
+ throw new TypeError("onNodeRequest() requires a paired onRequest() hook");
662
+ }
364
663
  if (arg.around !== undefined)
365
664
  this.around(arg.around);
366
665
  if (arg.beforeHandle !== undefined)
367
666
  this.beforeHandle(arg.beforeHandle);
368
667
  if (arg.afterHandle !== undefined)
369
668
  this.afterHandle(arg.afterHandle);
370
- if (arg.onResponse !== undefined)
371
- this.onResponse(arg.onResponse);
669
+ if (arg.onResponse !== undefined) {
670
+ this.assertConfigurable("onResponse()");
671
+ this.onResponseHooks.push(arg.onResponse);
672
+ this.onNodeResponseHooks.push(arg.onNodeResponse);
673
+ if (arg.onNodeResponse === undefined)
674
+ this.nodeResponseHooksComplete = false;
675
+ }
676
+ else if (arg.onNodeResponse !== undefined) {
677
+ throw new TypeError("onNodeResponse() requires a paired onResponse() hook");
678
+ }
679
+ // Before the bundle's own hooks: a bundle declaring both means its static values are the
680
+ // defaults its hook may then override, which is the order a single bundle reads in.
681
+ if (arg.responseHeaders !== undefined)
682
+ this.responseHeaders(arg.responseHeaders);
683
+ if (arg.onResponseHeaders !== undefined)
684
+ this.onResponseHeaders(arg.onResponseHeaders);
685
+ if (arg.onResponseBody !== undefined)
686
+ this.onResponseBody(arg.onResponseBody);
687
+ if (arg.onResponseRaw !== undefined)
688
+ this.onResponseRaw(arg.onResponseRaw);
372
689
  if (arg.onResponseFinalized !== undefined)
373
690
  this.onResponseFinalized(arg.onResponseFinalized);
374
691
  if (arg.onError !== undefined)
@@ -554,7 +871,9 @@ export class Server {
554
871
  const ledgered = this.effectLedgerRuntime?.resolve(capabilities, method, path);
555
872
  // A checked response schema needs the handler's VALUE before it becomes bytes, and the fused and
556
873
  // native lanes exist precisely to skip that step. So a contracted route leaves them - the same
557
- // trade an idempotent route makes above, and the reason this is opt-in per server.
874
+ // structural trade an idempotent route makes above. The check itself is not the cost: a compiled
875
+ // validator measures ~100ns/response, within benchmark noise on any route these lanes already
876
+ // exclude (middleware, derives, lifecycle hooks).
558
877
  const runtime = this.responseContractRuntime;
559
878
  const contracted = runtime !== undefined && schema?.response !== undefined
560
879
  ? { runtime, schema: schema.response }
@@ -589,24 +908,40 @@ export class Server {
589
908
  this.afterHandleHooks.length === 0 &&
590
909
  this.onErrorHooks.length === 0 &&
591
910
  this.aroundHooks.length === 0;
911
+ const bodyOnly = contracted === undefined &&
912
+ schema?.body !== undefined &&
913
+ schema.query === undefined &&
914
+ schema.params === undefined &&
915
+ this.derives.length === 0 &&
916
+ this.beforeHandleHooks.length === 0 &&
917
+ this.afterHandleHooks.length === 0 &&
918
+ this.onErrorHooks.length === 0;
919
+ const fusedBody = !bare &&
920
+ bodyOnly &&
921
+ schema.onValidationError === undefined &&
922
+ this.defaultOnValidationError === undefined &&
923
+ idempotent === undefined &&
924
+ ledgered === undefined &&
925
+ this.aroundHooks.length === 0;
926
+ // Body-only routes still need an async body read, but eligible routes can compile the validation +
927
+ // handler continuation once. The runner keeps the bounded parser and all error semantics while
928
+ // avoiding the generic entry/schema/lifecycle dispatch on Bun, Deno, and Node-direct.
929
+ const fusedBodyRunner = fusedBody
930
+ ? this.buildFusedBodyRunner(handler, schema.body, hasDecorations ? routeDecorations : undefined)
931
+ : undefined;
592
932
  const fusedWeb = bare && this.aroundHooks.length === 0
593
933
  ? this.buildFusedWeb(handler, hasDecorations ? routeDecorations : undefined, isContextlessNoArgArrow(handler))
594
934
  : fusedQuery
595
935
  ? this.buildFusedQueryWeb(handler, hasDecorations ? routeDecorations : undefined, schema.query)
596
- : undefined;
936
+ : fusedBody
937
+ ? this.buildFusedBodyWeb(fusedBodyRunner)
938
+ : undefined;
597
939
  const contextless = bare && this.aroundHooks.length === 0 && isContextlessNoArgArrow(handler);
598
940
  // The body and query lanes finalize the handler's result themselves, so a contracted route takes
599
941
  // the general lifecycle lane instead - one place owns the check rather than three.
600
942
  const lane = bare
601
943
  ? "bare"
602
- : contracted === undefined &&
603
- schema?.body !== undefined &&
604
- schema.query === undefined &&
605
- schema.params === undefined &&
606
- this.derives.length === 0 &&
607
- this.beforeHandleHooks.length === 0 &&
608
- this.afterHandleHooks.length === 0 &&
609
- this.onErrorHooks.length === 0
944
+ : bodyOnly
610
945
  ? "body"
611
946
  : contracted === undefined &&
612
947
  schema?.body === undefined &&
@@ -618,8 +953,34 @@ export class Server {
618
953
  this.onErrorHooks.length === 0
619
954
  ? "query"
620
955
  : "lifecycle";
621
- const execution = this.compileExecutionPlan(lane, contextless, this.aroundHooks.length > 0, ledgered !== undefined, fusedWeb, fusedWeb === undefined ? undefined : fusedQuery ? "query" : "bare");
622
- const entry = {
956
+ // Lifecycle routes with no params schema are the common middleware shape: a derive/before hook
957
+ // plus an optional query or body schema. Select their complete validation stage at registration
958
+ // so the request path never re-checks params/body presence. Parameter-schema routes retain the
959
+ // generic lifecycle runner until their more involved recovery matrix is selected explicitly.
960
+ const lifecycleLane = lane !== "lifecycle" || schema?.params !== undefined
961
+ ? undefined
962
+ : schema?.body !== undefined
963
+ ? schema.query !== undefined
964
+ ? "body-query"
965
+ : "body"
966
+ : schema?.query !== undefined
967
+ ? "query"
968
+ : "hooks";
969
+ // The realistic middleware shape is commonly exactly one synchronous-or-async derive followed
970
+ // by one before hook. Keep the generic runner for every route that can observe decorations,
971
+ // after hooks, error hooks, or response contracts; this lane only removes the two per-request
972
+ // hook-loop dispatches and preserves the same async continuations and error handling.
973
+ const lifecycleHookLane = lane === "lifecycle" &&
974
+ contracted === undefined &&
975
+ !hasDecorations &&
976
+ this.derives.length === 1 &&
977
+ this.beforeHandleHooks.length === 1 &&
978
+ this.afterHandleHooks.length === 0 &&
979
+ this.onErrorHooks.length === 0
980
+ ? "derive-before"
981
+ : 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");
983
+ const registeredEntry = {
623
984
  // (context: never) => unknown -> InternalHandler: the framework invokes it
624
985
  // with the concrete RawContext the typed handler expects, so this is sound.
625
986
  handler: handler,
@@ -633,6 +994,7 @@ export class Server {
633
994
  beforeHandle: [...this.beforeHandleHooks],
634
995
  afterHandle: [...this.afterHandleHooks],
635
996
  onError: [...this.onErrorHooks],
997
+ lifecycleHookLane,
636
998
  around: [...this.aroundHooks],
637
999
  execution,
638
1000
  };
@@ -673,17 +1035,17 @@ export class Server {
673
1035
  method,
674
1036
  path,
675
1037
  pattern,
676
- entry,
1038
+ entry: registeredEntry,
677
1039
  descriptor,
678
1040
  assurance: Object.freeze(routeAssurance),
679
1041
  };
680
1042
  }
681
1043
  /** Collapse route-invariant lifecycle decisions into one runner at registration. The request path
682
1044
  * performs no eligibility ladder: it supplies request state to this already-selected plan. */
683
- compileExecutionPlan(lane, contextless, hasAround, hasLedger, fusedWeb, fusedLane) {
1045
+ compileExecutionPlan(lane, contextless, hasAround, hasLedger, lifecycleLane, fusedWeb, fusedBody, fusedLane) {
684
1046
  if (contextless) {
685
- const run = (runtime, entry, source, params, search, signal, budget, platform, finalize, wrapResponse) => runtime.runContextlessBare(entry, source, params, search, signal, budget, platform, finalize, wrapResponse);
686
- return Object.freeze({ run, fusedWeb, fusedLane });
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 });
687
1049
  }
688
1050
  let inner;
689
1051
  switch (lane) {
@@ -697,13 +1059,30 @@ export class Server {
697
1059
  inner = (runtime, entry, _source, ctx, finalize, wrapResponse) => runtime.runQueryOnly(entry, ctx, finalize, wrapResponse);
698
1060
  break;
699
1061
  default:
700
- inner = (runtime, entry, source, ctx, finalize, wrapResponse) => runtime.runLifecycle(entry, source, ctx, finalize, wrapResponse);
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
+ }
701
1078
  }
702
1079
  const execute = hasAround
703
1080
  ? (runtime, entry, source, ctx, finalize, wrapResponse) => runtime.runWithAround(entry, ctx, () => inner(runtime, entry, source, ctx, finalize, wrapResponse), finalize, wrapResponse)
704
1081
  : inner;
705
- const run = (runtime, entry, source, params, search, signal, budget, platform, finalize, wrapResponse) => {
706
- const ctx = new RequestContext(source, params, search, signal, budget, platform, runtime.maxBodyBytes);
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);
707
1086
  let ledger;
708
1087
  // The runtime is always present when a route resolved a ledger (enforced at registration).
709
1088
  const ledgerRuntime = runtime.effectLedgerRuntime;
@@ -720,12 +1099,12 @@ export class Server {
720
1099
  }
721
1100
  return outcome;
722
1101
  };
723
- return Object.freeze({ run, fusedWeb, fusedLane });
1102
+ return Object.freeze({ run, fusedWeb, fusedBody, fusedLane });
724
1103
  }
725
1104
  /** The idempotency lane's bridge back into the normal matched lanes, resolved to a concrete Response
726
1105
  * (the lane buffers the body, then replays it through the route's real validation + handler). */
727
1106
  idempotencyRunLanes(buffered, platform, entry, params, search) {
728
- return Promise.resolve(this.runMatchedLanes(buffered, platform, entry, params, search, toResponse, IDENTITY_RESPONSE, RESPONSE_TIMEOUT, false));
1107
+ return Promise.resolve(this.runMatchedLanes(buffered, platform, entry, params, search, this.finalizeResponse, this.wrapWebResponse, this.webResponseTimeout, false));
729
1108
  }
730
1109
  /** @internal Symbol-keyed install seam for the `responseContract()` plugin. Off the public typed surface. */
731
1110
  [INSTALL_RESPONSE_CONTRACT](runtime) {
@@ -796,8 +1175,23 @@ export class Server {
796
1175
  this.sseRuntime ??= source.sseRuntime;
797
1176
  this.wsRuntime ??= source.wsRuntime;
798
1177
  this.onRequestHooks.push(...source.onRequestHooks);
1178
+ this.onNodeRequestHooks.push(...source.onNodeRequestHooks);
1179
+ this.nodeRequestHooksComplete &&= source.nodeRequestHooksComplete;
1180
+ // The group's static declarations came before its own response hooks, so they are folded in
1181
+ // first - and fold themselves into a hook here if this server already has one (same ordering
1182
+ // rule as a direct `responseHeaders()` call).
1183
+ if (source.staticResponseHeaders !== undefined) {
1184
+ this.addStaticResponseHeaders({ ...source.staticResponseHeaders.record });
1185
+ }
799
1186
  this.onResponseHooks.push(...source.onResponseHooks);
1187
+ this.onNodeResponseHooks.push(...source.onNodeResponseHooks);
1188
+ this.nodeResponseHooksComplete &&= source.nodeResponseHooksComplete;
800
1189
  this.onResponseFinalizedHooks.push(...source.onResponseFinalizedHooks);
1190
+ if (source.responseBodyTag !== undefined) {
1191
+ const owner = this.enableResponseBodyTagging();
1192
+ this.responseBodyOwners.add(source.responseBodyTag);
1193
+ source.responseBodyOwners.add(owner);
1194
+ }
801
1195
  this.globalAssurance.push(...source.globalAssurance);
802
1196
  this.mcpResourceList.push(...source.mcpResourceList);
803
1197
  this.mcpPromptList.push(...source.mcpPromptList);
@@ -811,14 +1205,19 @@ export class Server {
811
1205
  return route;
812
1206
  // Rebuild with the SAME builder that produced the closure - rebinding a query-fused route as
813
1207
  // bare would silently drop its validation.
814
- const fusedWeb = entry.execution.fusedLane === "query"
815
- ? this.buildFusedQueryWeb(entry.handler, entry.hasDecorations ? entry.decorations : undefined, entry.schema?.query)
816
- : this.buildFusedWeb(entry.handler, entry.hasDecorations ? entry.decorations : undefined, isContextlessNoArgArrow(entry.handler));
1208
+ const fusedBody = entry.execution.fusedLane === "body"
1209
+ ? this.buildFusedBodyRunner(entry.handler, entry.schema?.body, entry.hasDecorations ? entry.decorations : undefined)
1210
+ : undefined;
1211
+ const fusedWeb = entry.execution.fusedLane === "body"
1212
+ ? this.buildFusedBodyWeb(fusedBody)
1213
+ : 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));
817
1216
  return {
818
1217
  ...route,
819
1218
  entry: {
820
1219
  ...entry,
821
- execution: Object.freeze({ ...entry.execution, fusedWeb }),
1220
+ execution: Object.freeze({ ...entry.execution, fusedWeb, fusedBody }),
822
1221
  },
823
1222
  };
824
1223
  }
@@ -859,7 +1258,7 @@ export class Server {
859
1258
  // path, selected by the compiled execution plan), and an `async fetch` would wrap every such result in a redundant
860
1259
  // promise + microtask. Returning `Response | Promise<Response>` matches Web/edge handlers, while
861
1260
  // `await app.fetch(...)` continues to work exactly as before.
862
- const outcome = this.dispatch(source, platform, toResponse, IDENTITY_RESPONSE, RESPONSE_TIMEOUT, true);
1261
+ const outcome = this.dispatch(source, platform, this.finalizeResponse, this.wrapWebResponse, this.webResponseTimeout, true);
863
1262
  if (this.onResponseHooks.length === 0 && this.onResponseFinalizedHooks.length === 0) {
864
1263
  return outcome;
865
1264
  }
@@ -877,7 +1276,7 @@ export class Server {
877
1276
  return this.admitGated(requestOf(source), () => this.fetchMatchedInner(source, entry, params));
878
1277
  }
879
1278
  fetchMatchedInner(source, entry, params) {
880
- const outcome = this.runMatched(source, undefined, entry, params, undefined, toResponse, IDENTITY_RESPONSE, RESPONSE_TIMEOUT, true);
1279
+ const outcome = this.runMatched(source, undefined, entry, params, undefined, this.finalizeResponse, this.wrapWebResponse, this.webResponseTimeout, true);
881
1280
  if (this.onResponseHooks.length === 0 && this.onResponseFinalizedHooks.length === 0) {
882
1281
  return outcome;
883
1282
  }
@@ -927,20 +1326,28 @@ export class Server {
927
1326
  * Like {@link fetch}, but renders a plain-data result **without** building a Web `Response` - the
928
1327
  * `@nifrajs/node` adapter serializes the returned primitives straight to the socket, skipping the undici
929
1328
  * `Response` build + body drain (the bulk of the Node bridge cost, measured ≈4µs/req). A handler that
930
- * returns a `Response`, an error/short-circuit, or any registered `onResponse` hook falls back to the
931
- * full Web path (`{ kind: "response" }`), so behavior is identical - only the common JSON-data case is
932
- * faster. Same lifecycle as {@link fetch} (body cap, validation, hooks all run); only the final
933
- * render differs.
1329
+ * returns a `Response`, an error/short-circuit, or a response hook that replaces/consumes the buffered
1330
+ * body stays on the full Web path; an in-place response hook can still use the direct writer. Same
1331
+ * lifecycle as {@link fetch} (body cap, validation, hooks all run); only the final render differs.
934
1332
  */
935
1333
  resolveNode(req, platform) {
936
1334
  return this.resolveNodeSource(req, platform);
937
1335
  }
938
1336
  resolveNodeSource(source, platform, suppliedRuntime) {
939
- // onResponse hooks transform a Response, and the capacity gate wraps the Web response path - both
940
- // force the Web path here (the gated `fetchSource` admits/sheds/releases); wrap its result.
941
- if (this.onResponseHooks.length > 0 ||
942
- this.onResponseFinalizedHooks.length > 0 ||
943
- this.capacityGate !== undefined) {
1337
+ // A paired header-only native hook can preserve the Node-direct JSON/body outcome for successful
1338
+ // responses. Arbitrary onResponse transforms need a real Web Response, but a buffered outcome can
1339
+ // be materialized with a direct-write marker and return to the socket path when the hook mutates
1340
+ // it in place. Finalization observers and the capacity gate still wrap the complete Web path.
1341
+ // The native response lane engages only when the REQUEST side is native too (or there are no
1342
+ // request hooks at all). This is what makes the NodeRequestContext identity contract hold: a
1343
+ // web request-hook walk can rewrite the request, so its response-side view is a synthetic
1344
+ // wrapper - a different object - and any middleware carrying per-request state from its request
1345
+ // twin to its response twin through a WeakMap would silently miss. Coupling the gates means a
1346
+ // response twin always sees the exact object its request twin saw.
1347
+ const nativeResponseHooks = this.canUseNodeResponseHooks() &&
1348
+ (this.onRequestHooks.length === 0 || this.canUseNodeRequestHooks());
1349
+ const webResponseHooks = this.onResponseHooks.length > 0 && !nativeResponseHooks;
1350
+ if (this.onResponseFinalizedHooks.length > 0 || this.capacityGate !== undefined) {
944
1351
  const response = this.fetchSource(source, platform);
945
1352
  return response instanceof Promise
946
1353
  ? response.then((settled) => ({ kind: "response", response: settled }))
@@ -953,7 +1360,151 @@ export class Server {
953
1360
  if (runtime === undefined) {
954
1361
  throw new FrameworkError("NODE_DIRECT_RUNTIME_MISSING", "resolveNode() needs the Node-direct renderer. Normal @nifrajs/node serving installs it automatically; direct callers should add `.use(nodeDirect())` (from `@nifrajs/core/node-direct`).");
955
1362
  }
956
- return this.dispatch(source, platform, runtime.toOutcome, runtime.fromResponse, runtime.timeout, false);
1363
+ const resolved = this.dispatch(source, platform, runtime.toOutcome, runtime.fromResponse, runtime.timeout, false);
1364
+ // Fold declared static headers into the record ONCE, here: before any native twin runs (so a
1365
+ // header or body hook reads them through its view), and on the no-hook path too (which returns
1366
+ // the outcome straight to the writer without the finish step below).
1367
+ const statics = this.staticResponseHeaders;
1368
+ const outcome = statics === undefined
1369
+ ? resolved
1370
+ : resolved instanceof Promise
1371
+ ? resolved.then((settled) => withStaticNodeHeaders(settled, statics))
1372
+ : withStaticNodeHeaders(resolved, statics);
1373
+ if (webResponseHooks) {
1374
+ return outcome instanceof Promise
1375
+ ? outcome.then((settled) => this.finishNodeWebResponse(settled, source, runtime))
1376
+ : this.finishNodeWebResponse(outcome, source, runtime);
1377
+ }
1378
+ if (!nativeResponseHooks)
1379
+ return outcome;
1380
+ try {
1381
+ return outcome instanceof Promise
1382
+ ? outcome.then((settled) => this.finishNodeResponse(settled, source, runtime))
1383
+ : this.finishNodeResponse(outcome, source, runtime);
1384
+ }
1385
+ catch (error) {
1386
+ // Keep resolveNode's failure shape promise-based, matching app.fetch and the adapter bridge.
1387
+ return Promise.reject(error);
1388
+ }
1389
+ }
1390
+ /** Run generic Web response middleware while retaining direct writes for untouched buffered bodies. */
1391
+ finishNodeWebResponse(outcome, source, runtime) {
1392
+ const req = this.takeResponseRequest(source);
1393
+ const response = runtime.toResponse(outcome);
1394
+ const transformed = this.applyOnResponseAndFinalize(response, req);
1395
+ return transformed instanceof Promise
1396
+ ? transformed.then(runtime.fromResponse)
1397
+ : runtime.fromResponse(transformed);
1398
+ }
1399
+ /** True only when every transforming Web response hook has a header-only Node equivalent. */
1400
+ canUseNodeResponseHooks() {
1401
+ return this.onResponseHooks.length > 0 && this.nodeResponseHooksComplete;
1402
+ }
1403
+ /** Apply paired native hooks to data outcomes; preserve the complete Web hook pipeline for Response outcomes. */
1404
+ finishNodeResponse(outcome, source, runtime) {
1405
+ if (outcome.kind === "response") {
1406
+ const req = this.takeResponseRequest(source);
1407
+ const transformed = this.applyOnResponseAndFinalize(outcome.response, req);
1408
+ return transformed instanceof Promise
1409
+ ? transformed.then(runtime.fromResponse)
1410
+ : runtime.fromResponse(transformed);
1411
+ }
1412
+ let headers = outcome.headers;
1413
+ if (outcome.kind === "json" && outcome.body !== null) {
1414
+ // The json render adds its Content-Type at WRITE time, so a body hook checking content types
1415
+ // would see nothing. Materialize the writer's own value into the hook-visible record - same
1416
+ // string the writer would emit, so the wire is unchanged.
1417
+ const hasType = headers !== undefined &&
1418
+ Object.keys(headers).some((key) => key.toLowerCase() === "content-type");
1419
+ if (!hasType) {
1420
+ const defaultContentType = runtime.jsonContentType ?? "application/json;charset=utf-8";
1421
+ if (headers === undefined) {
1422
+ headers = { "content-type": defaultContentType };
1423
+ }
1424
+ else {
1425
+ // The record belongs to this outcome. Add the implicit JSON type in place so the native
1426
+ // header walk does not clone every c.set.headers record before it can run. A hook that
1427
+ // replaces or deletes the type still takes the existing withNodeResponseHeaders path;
1428
+ // the writer's final defaulting behavior remains unchanged.
1429
+ headers["content-type"] = defaultContentType;
1430
+ }
1431
+ }
1432
+ }
1433
+ const context = {
1434
+ status: outcome.status,
1435
+ headers,
1436
+ cookies: outcome.kind === "json" ? outcome.cookies : undefined,
1437
+ body: outcome.body,
1438
+ };
1439
+ const applied = this.applyNodeResponseHooks(context, this.takeNodeResponseRequest(source));
1440
+ if (applied instanceof Promise) {
1441
+ return applied.then(() => this.withNodeResponseHeaders(outcome, context));
1442
+ }
1443
+ return this.withNodeResponseHeaders(outcome, context);
1444
+ }
1445
+ withNodeResponseHeaders(outcome, context) {
1446
+ const outcomeCookies = outcome.kind === "json" ? outcome.cookies : undefined;
1447
+ const bodyChanged = context.body !== outcome.body;
1448
+ const statusChanged = context.status !== outcome.status;
1449
+ if (context.headers === outcome.headers &&
1450
+ context.cookies === outcomeCookies &&
1451
+ !bodyChanged &&
1452
+ !statusChanged) {
1453
+ return outcome;
1454
+ }
1455
+ let headers = context.headers;
1456
+ if (bodyChanged && headers !== undefined) {
1457
+ // A replaced body invalidates any explicitly carried length; the writers re-derive framing
1458
+ // from the final bytes.
1459
+ const stale = Object.keys(headers).find((key) => key.toLowerCase() === "content-length");
1460
+ if (stale !== undefined) {
1461
+ headers = { ...headers };
1462
+ delete headers[stale];
1463
+ }
1464
+ }
1465
+ if (outcome.kind === "json") {
1466
+ if (bodyChanged && context.body !== null && typeof context.body !== "string") {
1467
+ // A binary replacement can't ride the json render - switch to the buffered-body render,
1468
+ // folding queued cookies into explicit set-cookie lines so nothing is dropped.
1469
+ const record = Object.create(null);
1470
+ if (headers !== undefined)
1471
+ Object.assign(record, headers);
1472
+ if (context.cookies !== undefined && context.cookies.length > 0) {
1473
+ record["set-cookie"] = [...context.cookies];
1474
+ }
1475
+ return { kind: "body", status: context.status, headers: record, body: context.body };
1476
+ }
1477
+ return {
1478
+ ...outcome,
1479
+ status: context.status,
1480
+ headers,
1481
+ cookies: context.cookies,
1482
+ body: bodyChanged ? context.body : outcome.body,
1483
+ };
1484
+ }
1485
+ return {
1486
+ ...outcome,
1487
+ status: context.status,
1488
+ headers,
1489
+ body: bodyChanged
1490
+ ? (context.body ?? new Uint8Array(0))
1491
+ : outcome.body,
1492
+ };
1493
+ }
1494
+ /** Synchronous until a native response hook actually returns a Promise. */
1495
+ applyNodeResponseHooks(response, req) {
1496
+ for (let i = 0; i < this.onNodeResponseHooks.length; i++) {
1497
+ const hook = this.onNodeResponseHooks[i];
1498
+ const result = hook(response, req);
1499
+ if (result instanceof Promise)
1500
+ return result.then(() => this.continueNodeResponseHooks(i + 1, response, req));
1501
+ }
1502
+ }
1503
+ async continueNodeResponseHooks(start, response, req) {
1504
+ for (let i = start; i < this.onNodeResponseHooks.length; i++) {
1505
+ const hook = this.onNodeResponseHooks[i];
1506
+ await hook(response, req);
1507
+ }
957
1508
  }
958
1509
  /**
959
1510
  * Resolve a WebSocket upgrade - the seam every serving adapter uses. Returns `pass` (not a WS
@@ -1062,8 +1613,42 @@ export class Server {
1062
1613
  if (this.onRequestHooks.length === 0) {
1063
1614
  return this.routeAndRun(source, resolved, finalize, wrapResponse, onTimeout, webFast);
1064
1615
  }
1616
+ if (!webFast && this.canUseNodeRequestHooks()) {
1617
+ return this.runWithNodeRequest(source, resolved, finalize, wrapResponse, onTimeout);
1618
+ }
1065
1619
  return this.runWithOnRequest(source, resolved, finalize, wrapResponse, onTimeout, webFast);
1066
1620
  }
1621
+ /** Node-native request hook walk. A header-only hook can inspect the lazy source without forcing a
1622
+ * Web `Request`; arbitrary request rewrites and full Web hooks use {@link runWithOnRequest}. */
1623
+ runWithNodeRequest(source, platform, finalize, wrapResponse, onTimeout) {
1624
+ const hooks = this.onNodeRequestHooks;
1625
+ const request = this.nodeRequestContextOf(source);
1626
+ for (let i = 0; i < hooks.length; i++) {
1627
+ const hook = hooks[i];
1628
+ const outcome = hook(request, platform);
1629
+ if (outcome instanceof Promise) {
1630
+ return outcome.then((early) => this.continueNodeRequest(early, i + 1, request, source, platform, finalize, wrapResponse, onTimeout));
1631
+ }
1632
+ if (outcome !== undefined)
1633
+ return wrapResponse(outcome);
1634
+ }
1635
+ return this.routeAndRun(source, platform, finalize, wrapResponse, onTimeout, false);
1636
+ }
1637
+ async continueNodeRequest(first, nextIndex, request, source, platform, finalize, wrapResponse, onTimeout) {
1638
+ if (first !== undefined)
1639
+ return wrapResponse(first);
1640
+ for (let i = nextIndex; i < this.onNodeRequestHooks.length; i++) {
1641
+ const hook = this.onNodeRequestHooks[i];
1642
+ const outcome = hook(request, platform);
1643
+ const early = outcome instanceof Promise ? await outcome : outcome;
1644
+ if (early !== undefined)
1645
+ return wrapResponse(early);
1646
+ }
1647
+ return this.routeAndRun(source, platform, finalize, wrapResponse, onTimeout, false);
1648
+ }
1649
+ canUseNodeRequestHooks() {
1650
+ return this.onRequestHooks.length > 0 && this.nodeRequestHooksComplete;
1651
+ }
1067
1652
  /**
1068
1653
  * onRequest short-circuit path. Synchronous as long as every hook returns synchronously (the
1069
1654
  * common case - e.g. CORS returning `undefined` for a non-preflight request): an `async` version
@@ -1074,6 +1659,7 @@ export class Server {
1074
1659
  runWithOnRequest(source, platform, finalize, wrapResponse, onTimeout, webFast) {
1075
1660
  const hooks = this.onRequestHooks;
1076
1661
  const originalRequest = requestOf(source);
1662
+ this.responseSources.set(source, originalRequest);
1077
1663
  let current = source;
1078
1664
  for (let i = 0; i < hooks.length; i++) {
1079
1665
  const outcome = hooks[i](requestOf(current), platform);
@@ -1115,6 +1701,16 @@ export class Server {
1115
1701
  return this.routeAndRun(current, platform, finalize, wrapResponse, onTimeout, webFast);
1116
1702
  }
1117
1703
  takeResponseRequest(source) {
1704
+ const tracked = this.responseSources.get(source);
1705
+ if (tracked !== undefined) {
1706
+ this.responseSources.delete(source);
1707
+ const rewritten = this.responseRequests.get(tracked);
1708
+ if (rewritten !== undefined) {
1709
+ this.responseRequests.delete(tracked);
1710
+ return rewritten;
1711
+ }
1712
+ return tracked;
1713
+ }
1118
1714
  const request = requestOf(source);
1119
1715
  const rewritten = this.responseRequests.get(request);
1120
1716
  if (rewritten === undefined)
@@ -1122,14 +1718,99 @@ export class Server {
1122
1718
  this.responseRequests.delete(request);
1123
1719
  return rewritten;
1124
1720
  }
1721
+ /**
1722
+ * The NodeRequestContext for a source, MEMOIZED per source so the request twins and the response
1723
+ * twins receive the exact same object within one request - that identity is the documented
1724
+ * contract stateful twins key their WeakMaps on. An adapter source (which already speaks the
1725
+ * interface) is returned as-is; a plain `Request` source (a direct `resolveNode` caller) gets one
1726
+ * cached wrapper.
1727
+ */
1728
+ nodeRequestContextOf(source) {
1729
+ if (source.header !== undefined)
1730
+ return source;
1731
+ let context = this.nodeContexts.get(source);
1732
+ if (context === undefined) {
1733
+ const request = requestOf(source);
1734
+ context = {
1735
+ method: request.method,
1736
+ url: request.url,
1737
+ header: (name) => request.headers.get(name),
1738
+ };
1739
+ this.nodeContexts.set(source, context);
1740
+ }
1741
+ return context;
1742
+ }
1743
+ /** Preserve the request visible to generic onRequest hooks for paired native response hooks. */
1744
+ takeNodeResponseRequest(source) {
1745
+ const tracked = this.responseSources.get(source);
1746
+ if (tracked !== undefined) {
1747
+ this.responseSources.delete(source);
1748
+ const rewritten = this.responseRequests.get(tracked);
1749
+ if (rewritten !== undefined) {
1750
+ this.responseRequests.delete(tracked);
1751
+ return {
1752
+ method: rewritten.method,
1753
+ url: rewritten.url,
1754
+ header: (name) => rewritten.headers.get(name),
1755
+ };
1756
+ }
1757
+ return {
1758
+ method: tracked.method,
1759
+ url: tracked.url,
1760
+ header: (name) => tracked.headers.get(name),
1761
+ };
1762
+ }
1763
+ return this.nodeRequestContextOf(source);
1764
+ }
1125
1765
  /**
1126
1766
  * Route → build context → run. Synchronous through to the handler for a **bare** route
1127
1767
  * (selected by its compiled plan), so a sync handler produces its result with zero promise allocations;
1128
1768
  * routes with validation/hooks keep the full async {@link runLifecycle}, unchanged.
1129
1769
  */
1130
1770
  routeAndRun(source, platform, finalize, wrapResponse, onTimeout, webFast) {
1131
- const url = source.urlParts ?? urlPartsOf(source.url);
1132
- const match = this.catalog.find(source.method, url.pathname);
1771
+ // Routing only needs the pathname. Scan the URL once but keep the two slices in locals instead
1772
+ // of allocating the `{ pathname, search }` pair returned by the public helper on every request.
1773
+ let pathname;
1774
+ let search;
1775
+ // Read the source's split ONCE. `urlParts` is an accessor on the Node request sources that
1776
+ // rescans the target and allocates a fresh pair every time it is touched, so testing it and then
1777
+ // reading each half through the accessor scanned the URL three times per request and threw two
1778
+ // of the three pairs away immediately.
1779
+ const parts = source.urlParts;
1780
+ if (parts !== undefined) {
1781
+ pathname = parts.pathname;
1782
+ search = parts.search;
1783
+ }
1784
+ else {
1785
+ const rawUrl = source.url;
1786
+ const schemeEnd = rawUrl.indexOf("://");
1787
+ const start = schemeEnd === -1 ? rawUrl.indexOf("/") : rawUrl.indexOf("/", schemeEnd + 3);
1788
+ if (start === -1) {
1789
+ pathname = "/";
1790
+ search = "";
1791
+ }
1792
+ else {
1793
+ let pathEnd = rawUrl.length;
1794
+ let searchStart = -1;
1795
+ let searchEnd = rawUrl.length;
1796
+ for (let i = start; i < rawUrl.length; i++) {
1797
+ const c = rawUrl.charCodeAt(i);
1798
+ if (c === 63 /* ? */ && searchStart === -1) {
1799
+ pathEnd = i;
1800
+ searchStart = i;
1801
+ }
1802
+ else if (c === 35 /* # */) {
1803
+ if (searchStart === -1)
1804
+ pathEnd = i;
1805
+ searchEnd = i;
1806
+ break;
1807
+ }
1808
+ }
1809
+ pathname = rawUrl.slice(start, pathEnd);
1810
+ search = searchStart === -1 ? "" : rawUrl.slice(searchStart, searchEnd);
1811
+ }
1812
+ }
1813
+ const match = this.catalog.find(source.method, pathname);
1133
1814
  if (!match.found) {
1134
1815
  if (match.reason === "method-not-allowed") {
1135
1816
  return wrapResponse(jsonError(405, "method_not_allowed", { Allow: match.allowed.join(", ") }));
@@ -1142,7 +1823,7 @@ export class Server {
1142
1823
  if (params === null) {
1143
1824
  return wrapResponse(jsonError(400, "malformed_path"));
1144
1825
  }
1145
- return this.runMatched(source, platform, match.payload, params, url.search, finalize, wrapResponse, onTimeout, webFast);
1826
+ return this.runMatched(source, platform, match.payload, params, search, finalize, wrapResponse, onTimeout, webFast);
1146
1827
  }
1147
1828
  /** Run a route that has already been matched by the runtime or Nifra's portable router. */
1148
1829
  runMatched(source, platform, entry, params, search, finalize, wrapResponse, onTimeout, webFast) {
@@ -1192,9 +1873,12 @@ export class Server {
1192
1873
  ? getUnboundedRequestBudget()
1193
1874
  : createRequestBudget({ deadline: admission.deadline, signal });
1194
1875
  const plan = entry.execution;
1876
+ const nativeContext = controller === undefined;
1195
1877
  const outcome = webFast && plan.fusedWeb !== undefined
1196
- ? plan.fusedWeb(source, params, search, signal, budget, platform, false)
1197
- : plan.run(this, entry, source, params, search, signal, budget, platform, finalize, wrapResponse);
1878
+ ? plan.fusedWeb(source, params, search, signal, budget, platform, nativeContext)
1879
+ : !webFast && plan.fusedBody !== undefined
1880
+ ? plan.fusedBody(source, params, search, signal, budget, platform, false, finalize, wrapResponse)
1881
+ : plan.run(this, entry, source, params, search, signal, budget, platform, nativeContext, finalize, wrapResponse);
1198
1882
  // The request timeout only bounds work that is actually pending - a synchronous (bare) result is
1199
1883
  // already complete and can't time out, so it's returned as-is (no 503 race, no promise).
1200
1884
  if (controller !== undefined && outcome instanceof Promise) {
@@ -1266,11 +1950,13 @@ export class Server {
1266
1950
  * 500s), same respond semantics (the lifecycle parity suite pins it).
1267
1951
  */
1268
1952
  buildFusedWeb(handler, decorations, contextless) {
1953
+ // The fused lanes return to the runtime directly, so the framework's own error renders fold in
1954
+ // static headers here rather than through the shared `wrapResponse` seam.
1269
1955
  const logError = (err, ctx) => {
1270
1956
  if (err instanceof Response)
1271
- return err;
1957
+ return this.wrapWebResponse(err);
1272
1958
  this.logRequestError(err, ctx);
1273
- return jsonError(500, "internal_error");
1959
+ return this.wrapWebResponse(jsonError(500, "internal_error"));
1274
1960
  };
1275
1961
  if (contextless && decorations === undefined) {
1276
1962
  // `() => ...` can't observe the context - skip allocating one entirely (errors still build
@@ -1283,20 +1969,20 @@ export class Server {
1283
1969
  }
1284
1970
  catch (err) {
1285
1971
  return logError(err, nativeContext
1286
- ? RequestContext.native(source, params, this.maxBodyBytes)
1972
+ ? RequestContext.native(source, params, search, this.maxBodyBytes, platform)
1287
1973
  : new RequestContext(source, params, search, signal, budget, platform, this.maxBodyBytes));
1288
1974
  }
1289
1975
  if (result instanceof Promise) {
1290
- return result.then(fusedRespondNoSet, (err) => logError(err, nativeContext
1291
- ? RequestContext.native(source, params, this.maxBodyBytes)
1976
+ return result.then((value) => fusedRespondNoSet(value, this.responseBodyTag, this.staticResponseHeaders), (err) => logError(err, nativeContext
1977
+ ? RequestContext.native(source, params, search, this.maxBodyBytes, platform)
1292
1978
  : new RequestContext(source, params, search, signal, budget, platform, this.maxBodyBytes)));
1293
1979
  }
1294
- return fusedRespondNoSet(result);
1980
+ return fusedRespondNoSet(result, this.responseBodyTag, this.staticResponseHeaders);
1295
1981
  };
1296
1982
  }
1297
1983
  return (source, params, search, signal, budget, platform, nativeContext) => {
1298
1984
  const ctx = nativeContext
1299
- ? RequestContext.native(source, params, this.maxBodyBytes)
1985
+ ? RequestContext.native(source, params, search, this.maxBodyBytes, platform)
1300
1986
  : new RequestContext(source, params, search, signal, budget, platform, this.maxBodyBytes);
1301
1987
  if (decorations !== undefined)
1302
1988
  Object.assign(ctx, decorations);
@@ -1308,11 +1994,87 @@ export class Server {
1308
1994
  return logError(err, ctx);
1309
1995
  }
1310
1996
  if (result instanceof Promise) {
1311
- return result.then((value) => fusedRespond(value, ctx), (err) => logError(err, ctx));
1997
+ return result.then((value) => fusedRespond(value, ctx, this.responseBodyTag, this.staticResponseHeaders), (err) => logError(err, ctx));
1998
+ }
1999
+ return fusedRespond(result, ctx, this.responseBodyTag, this.staticResponseHeaders);
2000
+ };
2001
+ }
2002
+ /** Compile the eligible body-only route's parser → validator → handler continuation once. The
2003
+ * bounded parser remains shared with the generic lane; only the route-invariant entry lookups and
2004
+ * lifecycle dispatch disappear from the common synchronous-validator/synchronous-handler case. */
2005
+ buildFusedBodyRunner(handler, bodySchema, decorations) {
2006
+ const logError = (err, ctx, wrapResponse) => this.bareError(err, ctx, wrapResponse);
2007
+ const runHandler = (ctx, finalize, wrapResponse) => {
2008
+ if (decorations !== undefined)
2009
+ Object.assign(ctx, decorations);
2010
+ let output;
2011
+ try {
2012
+ output = handler(ctx);
2013
+ }
2014
+ catch (err) {
2015
+ return logError(err, ctx, wrapResponse);
2016
+ }
2017
+ if (output instanceof Promise) {
2018
+ return output.then((result) => {
2019
+ try {
2020
+ return finalize(result, responseSet(ctx), ctx);
2021
+ }
2022
+ catch (err) {
2023
+ return logError(err, ctx, wrapResponse);
2024
+ }
2025
+ }, (err) => logError(err, ctx, wrapResponse));
2026
+ }
2027
+ try {
2028
+ return finalize(output, responseSet(ctx), ctx);
2029
+ }
2030
+ catch (err) {
2031
+ return logError(err, ctx, wrapResponse);
2032
+ }
2033
+ };
2034
+ const runValidated = (result, ctx, finalize, wrapResponse) => {
2035
+ if (result.issues !== undefined)
2036
+ return wrapResponse(validationError(result.issues));
2037
+ ctx.body = result.value;
2038
+ return runHandler(ctx, finalize, wrapResponse);
2039
+ };
2040
+ const runParsed = (parsed, ctx, finalize, wrapResponse) => {
2041
+ let validation;
2042
+ try {
2043
+ validation = bodySchema["~standard"].validate(parsed);
2044
+ }
2045
+ catch (err) {
2046
+ return logError(err, ctx, wrapResponse);
2047
+ }
2048
+ if (validation instanceof Promise) {
2049
+ return validation.then((settled) => {
2050
+ try {
2051
+ return runValidated(settled, ctx, finalize, wrapResponse);
2052
+ }
2053
+ catch (err) {
2054
+ return logError(err, ctx, wrapResponse);
2055
+ }
2056
+ }, (err) => logError(err, ctx, wrapResponse));
2057
+ }
2058
+ try {
2059
+ return runValidated(validation, ctx, finalize, wrapResponse);
1312
2060
  }
1313
- return fusedRespond(result, ctx);
2061
+ catch (err) {
2062
+ return logError(err, ctx, wrapResponse);
2063
+ }
2064
+ };
2065
+ return (source, params, search, signal, budget, platform, nativeContext, finalize, wrapResponse) => {
2066
+ const ctx = nativeContext
2067
+ ? RequestContext.native(source, params, search, this.maxBodyBytes, platform)
2068
+ : new RequestContext(source, params, search, signal, budget, platform, this.maxBodyBytes);
2069
+ const finish = (value) => runParsed(value, ctx, finalize, wrapResponse);
2070
+ return this.readBodyInput(source, finish, wrapResponse, (err) => logError(err, ctx, wrapResponse));
1314
2071
  };
1315
2072
  }
2073
+ /** Web adapter wrapper for the shared body runner. Node passes its native finalizer directly through
2074
+ * the execution plan, while Web needs the live context to preserve lazy `c.set` controls. */
2075
+ buildFusedBodyWeb(body) {
2076
+ 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);
2077
+ }
1316
2078
  /** The fused Web lane for a route whose only lifecycle step is a query schema: parse + validate +
1317
2079
  * handler + respond in one closure, no lifecycle promise when the validator and handler are sync.
1318
2080
  * Eligibility is decided at registration (see `fusedQuery` in {@link register}); the semantics here
@@ -1321,11 +2083,13 @@ export class Server {
1321
2083
  * `Response` passes through, anything else logs and returns a flat 500, and an async validator
1322
2084
  * falls to a then-chain with the same steps. */
1323
2085
  buildFusedQueryWeb(handler, decorations, querySchema) {
2086
+ // The fused lanes return to the runtime directly, so the framework's own error renders fold in
2087
+ // static headers here rather than through the shared `wrapResponse` seam.
1324
2088
  const logError = (err, ctx) => {
1325
2089
  if (err instanceof Response)
1326
- return err;
2090
+ return this.wrapWebResponse(err);
1327
2091
  this.logRequestError(err, ctx);
1328
- return jsonError(500, "internal_error");
2092
+ return this.wrapWebResponse(jsonError(500, "internal_error"));
1329
2093
  };
1330
2094
  const runHandler = (ctx, value) => {
1331
2095
  ctx.query = value;
@@ -1337,13 +2101,13 @@ export class Server {
1337
2101
  return logError(err, ctx);
1338
2102
  }
1339
2103
  if (result instanceof Promise) {
1340
- return result.then((settled) => fusedRespond(settled, ctx), (err) => logError(err, ctx));
2104
+ return result.then((settled) => fusedRespond(settled, ctx, this.responseBodyTag, this.staticResponseHeaders), (err) => logError(err, ctx));
1341
2105
  }
1342
- return fusedRespond(result, ctx);
2106
+ return fusedRespond(result, ctx, this.responseBodyTag, this.staticResponseHeaders);
1343
2107
  };
1344
2108
  return (source, params, search, signal, budget, platform, nativeContext) => {
1345
2109
  const ctx = nativeContext
1346
- ? RequestContext.native(source, params, this.maxBodyBytes)
2110
+ ? RequestContext.native(source, params, search, this.maxBodyBytes, platform)
1347
2111
  : new RequestContext(source, params, search, signal, budget, platform, this.maxBodyBytes);
1348
2112
  if (decorations !== undefined)
1349
2113
  Object.assign(ctx, decorations);
@@ -1356,11 +2120,11 @@ export class Server {
1356
2120
  }
1357
2121
  if (validation instanceof Promise) {
1358
2122
  return validation.then((settled) => settled.issues !== undefined
1359
- ? validationError(settled.issues)
2123
+ ? this.wrapWebResponse(validationError(settled.issues))
1360
2124
  : runHandler(ctx, settled.value), (err) => logError(err, ctx));
1361
2125
  }
1362
2126
  if (validation.issues !== undefined)
1363
- return validationError(validation.issues);
2127
+ return this.wrapWebResponse(validationError(validation.issues));
1364
2128
  return runHandler(ctx, validation.value);
1365
2129
  };
1366
2130
  }
@@ -1398,25 +2162,20 @@ export class Server {
1398
2162
  return dispatch(0);
1399
2163
  }
1400
2164
  runBodyOnly(entry, source, ctx, finalize, wrapResponse) {
2165
+ return this.readBodyInput(source, (parsed) => this.finishBodyOnly(entry, parsed, ctx, finalize, wrapResponse), wrapResponse, (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse));
2166
+ }
2167
+ /** Shared bounded body framing/parser. Both body lanes use the same trust-boundary checks; only
2168
+ * the validation + handler continuation differs. */
2169
+ readBodyInput(source, onParsed, wrapResponse, onError) {
1401
2170
  const contentType = headerOf(source, "content-type") ?? "";
1402
2171
  if (contentType !== "application/json" && !contentType.includes("application/json")) {
1403
2172
  if (isUrlEncodedForm(contentType)) {
1404
- // HTML form posts validate through the same schema as JSON (parity with Elysia/Hono).
1405
- // Off the JSON inline lane on purpose: forms are a fraction of API traffic, and the
1406
- // bounded form reader shares the streaming guard. JSON requests are untouched.
1407
- return readBoundedForm(source, this.maxBodyBytes).then((form) => {
1408
- if (form instanceof Response)
1409
- return wrapResponse(form);
1410
- return this.finishBodyOnly(entry, form, ctx, finalize, wrapResponse);
1411
- });
2173
+ return readBoundedForm(source, this.maxBodyBytes).then((form) => (form instanceof Response ? wrapResponse(form) : onParsed(form)), onError);
1412
2174
  }
1413
2175
  return Promise.resolve(wrapResponse(jsonError(415, "unsupported_media_type")));
1414
2176
  }
1415
- // Inline fast path (profiled): a framed, in-cap, non-chunked body parses with the native
1416
- // `req.json()` directly - one promise, one `.then` closure per request. The generic
1417
- // `readBoundedJson` (an extra async-fn frame + per-request `finish`/`applyValidation`
1418
- // closures) is kept for the chunked / length-less / oversized cases it exists for.
1419
- // Semantics are identical to readBoundedJsonSource - same checks, same error codes.
2177
+ // A framed, in-cap, non-chunked body can use native json() directly. Chunked or length-less
2178
+ // bodies use readBoundedJson, which enforces the streaming cap before parsing.
1420
2179
  const declared = headerOf(source, "content-length");
1421
2180
  if (declared !== null) {
1422
2181
  const length = parseContentLength(declared);
@@ -1427,21 +2186,14 @@ export class Server {
1427
2186
  return Promise.resolve(wrapResponse(jsonError(413, "payload_too_large")));
1428
2187
  }
1429
2188
  if (headerOf(source, "transfer-encoding") === null) {
1430
- return source.json().then((parsed) => this.finishBodyOnly(entry, parsed, ctx, finalize, wrapResponse),
1431
- // Native json() rejection = malformed JSON; anything past parse goes through
1432
- // finishBodyOnly's own error handling.
1433
- () => wrapResponse(jsonError(400, "invalid_json")));
2189
+ return source.json().then(onParsed, () => wrapResponse(jsonError(400, "invalid_json")));
1434
2190
  }
1435
2191
  }
1436
2192
  try {
1437
- return this.readBoundedJson(source).then((parsed) => {
1438
- if (parsed instanceof Response)
1439
- return wrapResponse(parsed);
1440
- return this.finishBodyOnly(entry, parsed, ctx, finalize, wrapResponse);
1441
- }, (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse));
2193
+ return this.readBoundedJson(source).then((parsed) => (parsed instanceof Response ? wrapResponse(parsed) : onParsed(parsed)), onError);
1442
2194
  }
1443
2195
  catch (err) {
1444
- return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse);
2196
+ return Promise.resolve(onError(err));
1445
2197
  }
1446
2198
  }
1447
2199
  /** Validate + run the handler for the bodyOnly path - shared by the inline fast path and the
@@ -1622,7 +2374,11 @@ export class Server {
1622
2374
  throw error;
1623
2375
  });
1624
2376
  }
1625
- throw error;
2377
+ // A hook failure must surface as a REJECTION even when every prior step ran synchronously:
2378
+ // `fetch()` may now resolve without a promise, but its failure contract stays promise-shaped -
2379
+ // a synchronous throw here would escape `Promise.resolve(app.fetch(...))` bridges and
2380
+ // `.then()`-style callers entirely instead of reaching their rejection handling.
2381
+ return Promise.reject(error);
1626
2382
  }
1627
2383
  /** Notify terminal observers in order while isolating both sync and async failures. */
1628
2384
  notifyResponseFinalized(outcome, req) {
@@ -1685,100 +2441,314 @@ export class Server {
1685
2441
  * Generic over the render: a success value goes through `finalize(result, set)` (a Web `Response`, or
1686
2442
  * node-direct primitives); an early/error `Response` (validation, thrown, 500) through `wrapResponse`.
1687
2443
  */
1688
- async runLifecycle(entry, source, ctx, finalize, wrapResponse) {
2444
+ /** Synchronous until a validator, lifecycle hook, handler, or contract check actually returns a Promise. */
2445
+ runLifecycle(entry, source, ctx, finalize, wrapResponse) {
2446
+ try {
2447
+ const paramsSchema = entry.schema?.params;
2448
+ if (paramsSchema !== undefined) {
2449
+ const validation = paramsSchema["~standard"].validate(ctx.params);
2450
+ if (validation instanceof Promise) {
2451
+ return validation.then((result) => this.runLifecycleAfterParamsResult(entry, source, ctx, finalize, wrapResponse, result), (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse));
2452
+ }
2453
+ return this.runLifecycleAfterParamsResult(entry, source, ctx, finalize, wrapResponse, validation);
2454
+ }
2455
+ // Registration already knows whether this route has body/params/query validation. Most real
2456
+ // read routes have no body or params schema, so skip the generic stage ladder and go straight to
2457
+ // query validation + lifecycle hooks. The fallback below remains the complete path for routes
2458
+ // with body/params combinations and validation recovery.
2459
+ if (entry.schema?.body === undefined) {
2460
+ return this.runQueryAndLifecycle(entry, ctx, finalize, wrapResponse);
2461
+ }
2462
+ return this.runLifecycleAfterParams(entry, source, ctx, finalize, wrapResponse, undefined);
2463
+ }
2464
+ catch (err) {
2465
+ return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse);
2466
+ }
2467
+ }
2468
+ /** Registration-specialized query lifecycle: query validation (including recovery) → hooks. */
2469
+ runLifecycleQuery(entry, ctx, finalize, wrapResponse) {
2470
+ try {
2471
+ // The execution plan only selects this runner when the route has a query schema. Keeping the
2472
+ // non-null access here removes the per-request schema-presence branch from realistic GETs.
2473
+ const validation = entry.schema.query["~standard"].validate(queryObjectOf(ctx[CONTEXT_SEARCH]));
2474
+ if (validation instanceof Promise) {
2475
+ return validation.then((result) => {
2476
+ try {
2477
+ return this.runQueryAndLifecycleResult(entry, ctx, finalize, wrapResponse, result);
2478
+ }
2479
+ catch (err) {
2480
+ return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse);
2481
+ }
2482
+ }, (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse));
2483
+ }
2484
+ return this.runQueryAndLifecycleResult(entry, ctx, finalize, wrapResponse, validation);
2485
+ }
2486
+ catch (err) {
2487
+ return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse);
2488
+ }
2489
+ }
2490
+ /** Registration-specialized body lifecycle: bounded body validation (including recovery) → hooks. */
2491
+ runLifecycleBody(entry, source, ctx, finalize, wrapResponse) {
2492
+ return this.readAndValidateBody(source, entry, ctx).then((bodyError) => bodyError === undefined
2493
+ ? this.runLifecycleHooks(entry, ctx, finalize, wrapResponse)
2494
+ : wrapResponse(bodyError), (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse));
2495
+ }
2496
+ /** Registration-specialized body + query lifecycle: bounded body validation → query validation → hooks. */
2497
+ runLifecycleBodyQuery(entry, source, ctx, finalize, wrapResponse) {
2498
+ return this.readAndValidateBody(source, entry, ctx).then((bodyError) => bodyError === undefined
2499
+ ? this.runLifecycleQuery(entry, ctx, finalize, wrapResponse)
2500
+ : wrapResponse(bodyError), (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse));
2501
+ }
2502
+ /** Registration-specialized no-body lifecycle: query validation (if present) → derives/hooks. */
2503
+ runQueryAndLifecycle(entry, ctx, finalize, wrapResponse) {
2504
+ const querySchema = entry.schema?.query;
2505
+ return querySchema === undefined
2506
+ ? this.runLifecycleHooks(entry, ctx, finalize, wrapResponse)
2507
+ : this.runLifecycleQuery(entry, ctx, finalize, wrapResponse);
2508
+ }
2509
+ runQueryAndLifecycleResult(entry, ctx, finalize, wrapResponse, result) {
2510
+ const queryError = this.applyLifecycleValidation(entry, result, ctx, "query");
2511
+ if (queryError instanceof Promise) {
2512
+ return queryError.then((error) => error === undefined
2513
+ ? this.runLifecycleHooks(entry, ctx, finalize, wrapResponse)
2514
+ : wrapResponse(error), (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse));
2515
+ }
2516
+ if (queryError !== undefined)
2517
+ return wrapResponse(queryError);
2518
+ return this.runLifecycleHooks(entry, ctx, finalize, wrapResponse);
2519
+ }
2520
+ runLifecycleAfterParamsResult(entry, source, ctx, finalize, wrapResponse, result) {
2521
+ try {
2522
+ return this.runLifecycleAfterParams(entry, source, ctx, finalize, wrapResponse, this.applyLifecycleValidation(entry, result, ctx, "params"));
2523
+ }
2524
+ catch (err) {
2525
+ return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse);
2526
+ }
2527
+ }
2528
+ runLifecycleAfterParams(entry, source, ctx, finalize, wrapResponse, paramsError) {
2529
+ if (paramsError instanceof Promise) {
2530
+ return paramsError.then((error) => this.runLifecycleAfterParamsSettled(entry, source, ctx, finalize, wrapResponse, error), (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse));
2531
+ }
2532
+ return this.runLifecycleAfterParamsSettled(entry, source, ctx, finalize, wrapResponse, paramsError);
2533
+ }
2534
+ runLifecycleAfterParamsSettled(entry, source, ctx, finalize, wrapResponse, paramsError) {
2535
+ if (paramsError !== undefined)
2536
+ return wrapResponse(paramsError);
2537
+ if (entry.schema?.body !== undefined) {
2538
+ return this.readAndValidateBody(source, entry, ctx).then((bodyError) => this.runLifecycleAfterBody(entry, ctx, finalize, wrapResponse, bodyError), (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse));
2539
+ }
2540
+ return this.runLifecycleAfterBody(entry, ctx, finalize, wrapResponse, undefined);
2541
+ }
2542
+ runLifecycleAfterBody(entry, ctx, finalize, wrapResponse, bodyError) {
2543
+ if (bodyError !== undefined)
2544
+ return wrapResponse(bodyError);
2545
+ const querySchema = entry.schema?.query;
2546
+ if (querySchema === undefined)
2547
+ return this.runLifecycleHooks(entry, ctx, finalize, wrapResponse);
2548
+ const validation = querySchema["~standard"].validate(queryObjectOf(ctx[CONTEXT_SEARCH]));
2549
+ if (validation instanceof Promise) {
2550
+ return validation.then((result) => this.runLifecycleAfterQueryResult(entry, ctx, finalize, wrapResponse, result), (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse));
2551
+ }
2552
+ return this.runLifecycleAfterQueryResult(entry, ctx, finalize, wrapResponse, validation);
2553
+ }
2554
+ runLifecycleAfterQueryResult(entry, ctx, finalize, wrapResponse, result) {
2555
+ try {
2556
+ const queryError = this.applyLifecycleValidation(entry, result, ctx, "query");
2557
+ if (queryError instanceof Promise) {
2558
+ return queryError.then((error) => this.runLifecycleAfterQuery(entry, ctx, finalize, wrapResponse, error), (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse));
2559
+ }
2560
+ return this.runLifecycleAfterQuery(entry, ctx, finalize, wrapResponse, queryError);
2561
+ }
2562
+ catch (err) {
2563
+ return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse);
2564
+ }
2565
+ }
2566
+ runLifecycleAfterQuery(entry, ctx, finalize, wrapResponse, queryError) {
2567
+ if (queryError !== undefined)
2568
+ return wrapResponse(queryError);
2569
+ return this.runLifecycleHooks(entry, ctx, finalize, wrapResponse);
2570
+ }
2571
+ runLifecycleHooks(entry, ctx, finalize, wrapResponse) {
2572
+ if (entry.lifecycleHookLane === "derive-before") {
2573
+ return this.runLifecycleDeriveBefore(entry, ctx, finalize, wrapResponse);
2574
+ }
1689
2575
  try {
1690
- if (entry.schema?.params !== undefined) {
1691
- // Path params arrive as strings from routing; validate (and coerce) them at the boundary, before
1692
- // the body/query, so a malformed `:id` is a 422 before any work runs. Raw `.validate` (no wrapper
1693
- // alloc), same recovery-hook path as body/query.
1694
- const validation = entry.schema.params["~standard"].validate(ctx.params);
1695
- const result = validation instanceof Promise ? await validation : validation;
1696
- const paramsError = await this.applyLifecycleValidation(entry, result, ctx, "params");
1697
- if (paramsError !== undefined)
1698
- return wrapResponse(paramsError);
1699
- }
1700
- if (entry.schema?.body !== undefined) {
1701
- const bodyError = await this.readAndValidateBody(source, entry, ctx);
1702
- if (bodyError !== undefined)
1703
- return wrapResponse(bodyError);
1704
- }
1705
- if (entry.schema?.query !== undefined) {
1706
- // Repeated query keys promote to `string[]` (so an array schema validates `?a=1&a=2`); a
1707
- // single occurrence stays a string. Raw `.validate` (read `.issues`/`.value`) skips the
1708
- // validateStandard wrapper alloc.
1709
- const validation = entry.schema.query["~standard"].validate(queryObjectOf(ctx[CONTEXT_SEARCH]));
1710
- const result = validation instanceof Promise ? await validation : validation;
1711
- const queryError = await this.applyLifecycleValidation(entry, result, ctx, "query");
1712
- if (queryError !== undefined)
1713
- return wrapResponse(queryError);
1714
- }
1715
- // Context extensions: static decorations, then per-request derives.
1716
- // Each hook below only awaits when it actually returns a Promise - a sync hook
1717
- // skips the microtask tick (a fast-path; ~64 ns/hook).
1718
2576
  if (entry.hasDecorations)
1719
- Object.assign(ctx, entry.decorations); // skip the no-op on bare routes
1720
- // The `.length` guards skip iterator setup for the common no-hook route (most
1721
- // routes have neither derives nor before/after hooks); a hooked route pays only
1722
- // the length check.
1723
- if (entry.derives.length > 0) {
1724
- for (const derive of entry.derives) {
1725
- const extension = derive(ctx);
1726
- Object.assign(ctx, extension instanceof Promise ? await extension : extension);
2577
+ Object.assign(ctx, entry.decorations);
2578
+ for (let i = 0; i < entry.derives.length; i++) {
2579
+ const extension = entry.derives[i](ctx);
2580
+ if (extension instanceof Promise) {
2581
+ return this.continueLifecycleAfterDerive(entry, ctx, finalize, wrapResponse, i, extension);
1727
2582
  }
2583
+ Object.assign(ctx, extension);
1728
2584
  }
1729
- // beforeHandle: a non-undefined return short-circuits, skipping the handler.
1730
- if (entry.beforeHandle.length > 0) {
1731
- for (const hook of entry.beforeHandle) {
1732
- const outcome = hook(ctx);
1733
- const early = outcome instanceof Promise ? await outcome : outcome;
1734
- if (early !== undefined)
1735
- return finalize(early, responseSet(ctx));
2585
+ for (let i = 0; i < entry.beforeHandle.length; i++) {
2586
+ const outcome = entry.beforeHandle[i](ctx);
2587
+ if (outcome instanceof Promise) {
2588
+ return this.continueLifecycleAfterBefore(entry, ctx, finalize, wrapResponse, i, outcome);
1736
2589
  }
2590
+ if (outcome !== undefined)
2591
+ return finalize(outcome, responseSet(ctx));
2592
+ }
2593
+ const result = entry.handler(ctx);
2594
+ if (result instanceof Promise) {
2595
+ return result.then((value) => this.finishLifecycleResult(entry, ctx, finalize, wrapResponse, value), (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse));
2596
+ }
2597
+ return this.finishLifecycleResult(entry, ctx, finalize, wrapResponse, result);
2598
+ }
2599
+ catch (err) {
2600
+ return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse);
2601
+ }
2602
+ }
2603
+ /** Registration-specialized derive → before → handler lifecycle. */
2604
+ runLifecycleDeriveBefore(entry, ctx, finalize, wrapResponse) {
2605
+ try {
2606
+ const extension = entry.derives[0](ctx);
2607
+ if (extension instanceof Promise) {
2608
+ return this.continueLifecycleAfterDerive(entry, ctx, finalize, wrapResponse, 0, extension);
2609
+ }
2610
+ Object.assign(ctx, extension);
2611
+ const outcome = entry.beforeHandle[0](ctx);
2612
+ if (outcome instanceof Promise) {
2613
+ return this.continueLifecycleAfterBefore(entry, ctx, finalize, wrapResponse, 0, outcome);
1737
2614
  }
1738
- const handlerOutput = entry.handler(ctx);
1739
- let result = handlerOutput instanceof Promise ? await handlerOutput : handlerOutput;
1740
- // afterHandle: transform the result before serialization.
1741
- if (entry.afterHandle.length > 0) {
1742
- for (const hook of entry.afterHandle) {
1743
- const transformed = hook(result, ctx);
1744
- result = transformed instanceof Promise ? await transformed : transformed;
2615
+ if (outcome !== undefined)
2616
+ return this.finishSimpleLifecycleResult(entry, ctx, finalize, wrapResponse, outcome);
2617
+ const result = entry.handler(ctx);
2618
+ if (result instanceof Promise) {
2619
+ return result.then((value) => this.finishSimpleLifecycleResult(entry, ctx, finalize, wrapResponse, value), (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse));
2620
+ }
2621
+ return this.finishSimpleLifecycleResult(entry, ctx, finalize, wrapResponse, result);
2622
+ }
2623
+ catch (err) {
2624
+ return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse);
2625
+ }
2626
+ }
2627
+ /** Finalize the specialized lane without rechecking its registration-proven invariants. */
2628
+ finishSimpleLifecycleResult(entry, ctx, finalize, wrapResponse, result) {
2629
+ try {
2630
+ return finalize(result, responseSet(ctx));
2631
+ }
2632
+ catch (err) {
2633
+ return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse);
2634
+ }
2635
+ }
2636
+ async continueLifecycleAfterDerive(entry, ctx, finalize, wrapResponse, firstIndex, first) {
2637
+ try {
2638
+ Object.assign(ctx, await first);
2639
+ for (let i = firstIndex + 1; i < entry.derives.length; i++) {
2640
+ const extension = entry.derives[i](ctx);
2641
+ Object.assign(ctx, extension instanceof Promise ? await extension : extension);
2642
+ }
2643
+ return await this.runLifecycleHooksAsync(entry, ctx, finalize, wrapResponse);
2644
+ }
2645
+ catch (err) {
2646
+ return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse);
2647
+ }
2648
+ }
2649
+ async runLifecycleHooksAsync(entry, ctx, finalize, wrapResponse) {
2650
+ try {
2651
+ for (const hook of entry.beforeHandle) {
2652
+ const outcome = hook(ctx);
2653
+ const early = outcome instanceof Promise ? await outcome : outcome;
2654
+ if (early !== undefined)
2655
+ return finalize(early, responseSet(ctx));
2656
+ }
2657
+ return await this.runLifecycleHandlerAsync(entry, ctx, finalize, wrapResponse);
2658
+ }
2659
+ catch (err) {
2660
+ return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse);
2661
+ }
2662
+ }
2663
+ async continueLifecycleAfterBefore(entry, ctx, finalize, wrapResponse, firstIndex, first) {
2664
+ try {
2665
+ const firstOutcome = await first;
2666
+ if (firstOutcome !== undefined)
2667
+ return finalize(firstOutcome, responseSet(ctx));
2668
+ for (let i = firstIndex + 1; i < entry.beforeHandle.length; i++) {
2669
+ const outcome = entry.beforeHandle[i](ctx);
2670
+ const early = outcome instanceof Promise ? await outcome : outcome;
2671
+ if (early !== undefined)
2672
+ return finalize(early, responseSet(ctx));
2673
+ }
2674
+ return await this.runLifecycleHandlerAsync(entry, ctx, finalize, wrapResponse);
2675
+ }
2676
+ catch (err) {
2677
+ return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse);
2678
+ }
2679
+ }
2680
+ async runLifecycleHandlerAsync(entry, ctx, finalize, wrapResponse) {
2681
+ try {
2682
+ const output = entry.handler(ctx);
2683
+ const result = output instanceof Promise ? await output : output;
2684
+ return await this.finishLifecycleResult(entry, ctx, finalize, wrapResponse, result);
2685
+ }
2686
+ catch (err) {
2687
+ return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse);
2688
+ }
2689
+ }
2690
+ finishLifecycleResult(entry, ctx, finalize, wrapResponse, initial) {
2691
+ try {
2692
+ let result = initial;
2693
+ for (let i = 0; i < entry.afterHandle.length; i++) {
2694
+ const transformed = entry.afterHandle[i](result, ctx);
2695
+ if (transformed instanceof Promise) {
2696
+ return this.continueLifecycleAfterHandle(entry, ctx, finalize, wrapResponse, i, transformed);
1745
2697
  }
2698
+ result = transformed;
1746
2699
  }
1747
- // The response contract is checked LAST, after afterHandle, so it holds what actually ships
1748
- // rather than what the handler happened to return. Only reached by a contracted route: the
1749
- // registration decision above kept those off the fused/native lanes for exactly this step.
2700
+ return this.finishLifecycleContract(entry, ctx, finalize, wrapResponse, result);
2701
+ }
2702
+ catch (err) {
2703
+ return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse);
2704
+ }
2705
+ }
2706
+ async continueLifecycleAfterHandle(entry, ctx, finalize, wrapResponse, firstIndex, first) {
2707
+ try {
2708
+ let result = await first;
2709
+ for (let i = firstIndex + 1; i < entry.afterHandle.length; i++) {
2710
+ const transformed = entry.afterHandle[i](result, ctx);
2711
+ result = transformed instanceof Promise ? await transformed : transformed;
2712
+ }
2713
+ return await this.finishLifecycleContract(entry, ctx, finalize, wrapResponse, result);
2714
+ }
2715
+ catch (err) {
2716
+ return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse);
2717
+ }
2718
+ }
2719
+ finishLifecycleContract(entry, ctx, finalize, wrapResponse, result) {
2720
+ try {
1750
2721
  const contract = entry.responseContract;
1751
- if (contract !== undefined) {
1752
- const checked = contract.runtime.check(contract.schema, result);
1753
- const outcome = checked instanceof Promise ? await checked : checked;
1754
- if (outcome.kind === "violation") {
1755
- // The handler broke a contract it declared itself, so this is a server fault, not the
1756
- // caller's. Fail closed: the undeclared payload is never sent, and the detail goes to the
1757
- // (redacting) logger rather than to the client.
1758
- this.logger.error("response contract violation", {
1759
- method: ctx.req.method,
1760
- path: pathnameOf(ctx.req.url),
1761
- // `detail`, not `message`: the logger uses `message` for its own first argument, so a
1762
- // field of that name is silently overwritten and the diagnosis disappears.
1763
- detail: outcome.message,
1764
- });
1765
- return wrapResponse(jsonError(500, "internal_error"));
1766
- }
1767
- if (outcome.kind === "warn") {
1768
- this.logger.warn("response contract", {
1769
- method: ctx.req.method,
1770
- path: pathnameOf(ctx.req.url),
1771
- detail: outcome.message,
1772
- });
1773
- }
1774
- result = outcome.value;
2722
+ if (contract === undefined)
2723
+ return finalize(result, responseSet(ctx));
2724
+ const checked = contract.runtime.check(contract.schema, result);
2725
+ if (checked instanceof Promise) {
2726
+ return checked.then((outcome) => this.finishContractOutcome(ctx, finalize, wrapResponse, outcome), (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse));
1775
2727
  }
1776
- return finalize(result, responseSet(ctx));
2728
+ return this.finishContractOutcome(ctx, finalize, wrapResponse, checked);
1777
2729
  }
1778
2730
  catch (err) {
1779
2731
  return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse);
1780
2732
  }
1781
2733
  }
2734
+ finishContractOutcome(ctx, finalize, wrapResponse, outcome) {
2735
+ if (outcome.kind === "violation") {
2736
+ this.logger.error("response contract violation", {
2737
+ method: ctx.req.method,
2738
+ path: pathnameOf(ctx.req.url),
2739
+ detail: outcome.message,
2740
+ });
2741
+ return wrapResponse(jsonError(500, "internal_error"));
2742
+ }
2743
+ if (outcome.kind === "warn") {
2744
+ this.logger.warn("response contract", {
2745
+ method: ctx.req.method,
2746
+ path: pathnameOf(ctx.req.url),
2747
+ detail: outcome.message,
2748
+ });
2749
+ }
2750
+ return finalize(outcome.value, responseSet(ctx));
2751
+ }
1782
2752
  async handleLifecycleError(entry, err, ctx, finalize, wrapResponse) {
1783
2753
  // A *thrown* Response is deliberate control flow, not an error - a guard throws a redirect/401,
1784
2754
  // an action throws an error page. Return it as-is (Remix/SvelteKit semantics); don't run onError
@@ -1838,7 +2808,7 @@ export class Server {
1838
2808
  }
1839
2809
  /** Apply validation and its recovery hook on the generic lifecycle lane. Recovery is completed
1840
2810
  * before derives/beforeHandle run, matching the body-only and query-only execution lanes. */
1841
- async applyLifecycleValidation(entry, result, ctx, kind) {
2811
+ applyLifecycleValidation(entry, result, ctx, kind) {
1842
2812
  const assign = (value) => {
1843
2813
  if (kind === "body")
1844
2814
  ctx.body = value;
@@ -1855,9 +2825,14 @@ export class Server {
1855
2825
  if (hook === undefined)
1856
2826
  return validationError(result.issues);
1857
2827
  const attempted = hook(result.issues, ctx, kind);
1858
- const recovery = attempted instanceof Promise ? await attempted : attempted;
2828
+ if (attempted instanceof Promise) {
2829
+ return attempted.then((recovery) => this.finishLifecycleValidationRecovery(entry, kind, result.issues, recovery, assign));
2830
+ }
2831
+ return this.finishLifecycleValidationRecovery(entry, kind, result.issues, attempted, assign);
2832
+ }
2833
+ finishLifecycleValidationRecovery(entry, kind, issues, recovery, assign) {
1859
2834
  if (recovery === undefined)
1860
- return validationError(result.issues);
2835
+ return validationError(issues);
1861
2836
  if (recovery instanceof Response)
1862
2837
  return recovery;
1863
2838
  const schema = kind === "body"
@@ -1866,10 +2841,17 @@ export class Server {
1866
2841
  ? entry.schema?.query
1867
2842
  : entry.schema?.params;
1868
2843
  const retried = schema["~standard"].validate(recovery);
1869
- const settled = retried instanceof Promise ? await retried : retried;
1870
- if (settled.issues !== undefined)
1871
- return validationError(settled.issues);
1872
- assign(settled.value);
2844
+ if (retried instanceof Promise) {
2845
+ return retried.then((settled) => {
2846
+ if (settled.issues !== undefined)
2847
+ return validationError(settled.issues);
2848
+ assign(settled.value);
2849
+ return undefined;
2850
+ });
2851
+ }
2852
+ if (retried.issues !== undefined)
2853
+ return validationError(retried.issues);
2854
+ assign(retried.value);
1873
2855
  return undefined;
1874
2856
  }
1875
2857
  /**