@nifrajs/core 2.8.2 → 2.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) 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 +26 -6
  10. package/dist/router/router.js.map +1 -1
  11. package/dist/server/context.d.ts +2 -0
  12. package/dist/server/context.d.ts.map +1 -1
  13. package/dist/server/cookies.d.ts.map +1 -1
  14. package/dist/server/cookies.js +4 -1
  15. package/dist/server/cookies.js.map +1 -1
  16. package/dist/server/http.d.ts.map +1 -1
  17. package/dist/server/http.js +16 -3
  18. package/dist/server/http.js.map +1 -1
  19. package/dist/server/node-outcome-hook.d.ts +78 -1
  20. package/dist/server/node-outcome-hook.d.ts.map +1 -1
  21. package/dist/server/node-outcome-hook.js +197 -1
  22. package/dist/server/node-outcome-hook.js.map +1 -1
  23. package/dist/server/node-outcome.d.ts +22 -2
  24. package/dist/server/node-outcome.d.ts.map +1 -1
  25. package/dist/server/node-outcome.js +86 -11
  26. package/dist/server/node-outcome.js.map +1 -1
  27. package/dist/server/query.d.ts +2 -0
  28. package/dist/server/query.d.ts.map +1 -1
  29. package/dist/server/query.js +27 -1
  30. package/dist/server/query.js.map +1 -1
  31. package/dist/server/request-context.d.ts +8 -1
  32. package/dist/server/request-context.d.ts.map +1 -1
  33. package/dist/server/request-context.js +23 -2
  34. package/dist/server/request-context.js.map +1 -1
  35. package/dist/server/respond.d.ts +29 -3
  36. package/dist/server/respond.d.ts.map +1 -1
  37. package/dist/server/respond.js +279 -16
  38. package/dist/server/respond.js.map +1 -1
  39. package/dist/server/response-contract-lane.d.ts +17 -3
  40. package/dist/server/response-contract-lane.d.ts.map +1 -1
  41. package/dist/server/response-contract-lane.js +6 -3
  42. package/dist/server/response-contract-lane.js.map +1 -1
  43. package/dist/server/server-types.d.ts +37 -0
  44. package/dist/server/server-types.d.ts.map +1 -1
  45. package/dist/server/server.d.ts +151 -7
  46. package/dist/server/server.d.ts.map +1 -1
  47. package/dist/server/server.js +1163 -174
  48. package/dist/server/server.js.map +1 -1
  49. package/dist/server/static-headers.d.ts +47 -0
  50. package/dist/server/static-headers.d.ts.map +1 -0
  51. package/dist/server/static-headers.js +109 -0
  52. package/dist/server/static-headers.js.map +1 -0
  53. package/dist/server.d.ts +2 -1
  54. package/dist/server.d.ts.map +1 -1
  55. package/dist/server.js +1 -0
  56. package/dist/server.js.map +1 -1
  57. package/dist/transport-codec.d.ts +15 -0
  58. package/dist/transport-codec.d.ts.map +1 -1
  59. package/dist/transport-codec.js +49 -10
  60. package/dist/transport-codec.js.map +1 -1
  61. package/package.json +1 -1
  62. package/src/index.ts +1 -1
  63. package/src/internal/route-execution.ts +19 -1
  64. package/src/node-direct.ts +7 -1
  65. package/src/router/router.ts +24 -6
  66. package/src/server/context.ts +2 -0
  67. package/src/server/cookies.ts +4 -1
  68. package/src/server/http.ts +16 -3
  69. package/src/server/node-outcome-hook.ts +315 -1
  70. package/src/server/node-outcome.ts +100 -14
  71. package/src/server/query.ts +26 -1
  72. package/src/server/request-context.ts +23 -1
  73. package/src/server/respond.ts +386 -17
  74. package/src/server/response-contract-lane.ts +17 -3
  75. package/src/server/server-types.ts +42 -0
  76. package/src/server/server.ts +1618 -237
  77. package/src/server/static-headers.ts +142 -0
  78. package/src/server.ts +9 -0
  79. package/src/transport-codec.ts +55 -10
@@ -9,10 +9,150 @@ import { EMPTY_PARAMS, Router } from "../router/router.js";
9
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. */
@@ -223,9 +388,20 @@ export class Server {
223
388
  this.onErrorHooks = [];
224
389
  this.aroundHooks = [];
225
390
  this.onRequestHooks = [];
391
+ this.onNodeRequestHooks = [];
392
+ this.nodeRequestHooksComplete = true;
226
393
  this.onResponseHooks = [];
394
+ this.onNodeResponseHooks = [];
395
+ this.nodeResponseHooksComplete = true;
227
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();
228
402
  this.responseRequests = new WeakMap();
403
+ this.responseSources = new WeakMap();
404
+ this.nodeContexts = new WeakMap();
229
405
  this.appliedPlugins = new Set();
230
406
  this.activeAssurance = [];
231
407
  this.globalAssurance = [];
@@ -256,6 +432,8 @@ export class Server {
256
432
  onRequest(fn) {
257
433
  this.assertConfigurable("onRequest()");
258
434
  this.onRequestHooks.push(fn);
435
+ this.onNodeRequestHooks.push(undefined);
436
+ this.nodeRequestHooksComplete = false;
259
437
  return this;
260
438
  }
261
439
  /** Run after validation, before the handler; a non-`undefined` return short-circuits. Order-scoped. */
@@ -317,6 +495,115 @@ export class Server {
317
495
  onResponse(fn) {
318
496
  this.assertConfigurable("onResponse()");
319
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
+ });
320
607
  return this;
321
608
  }
322
609
  /** Observe the terminal response after all transformations. Observers are ordered and fail-open. */
@@ -363,16 +650,42 @@ export class Server {
363
650
  }
364
651
  this.globalAssurance.push(...evidence.filter((item) => item.scope === "global"));
365
652
  this.activeAssurance.push(...evidence.filter((item) => item.scope === "subsequent"));
366
- if (arg.onRequest !== undefined)
367
- 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
+ }
368
663
  if (arg.around !== undefined)
369
664
  this.around(arg.around);
370
665
  if (arg.beforeHandle !== undefined)
371
666
  this.beforeHandle(arg.beforeHandle);
372
667
  if (arg.afterHandle !== undefined)
373
668
  this.afterHandle(arg.afterHandle);
374
- if (arg.onResponse !== undefined)
375
- 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);
376
689
  if (arg.onResponseFinalized !== undefined)
377
690
  this.onResponseFinalized(arg.onResponseFinalized);
378
691
  if (arg.onError !== undefined)
@@ -558,7 +871,9 @@ export class Server {
558
871
  const ledgered = this.effectLedgerRuntime?.resolve(capabilities, method, path);
559
872
  // A checked response schema needs the handler's VALUE before it becomes bytes, and the fused and
560
873
  // native lanes exist precisely to skip that step. So a contracted route leaves them - the same
561
- // 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).
562
877
  const runtime = this.responseContractRuntime;
563
878
  const contracted = runtime !== undefined && schema?.response !== undefined
564
879
  ? { runtime, schema: schema.response }
@@ -593,24 +908,40 @@ export class Server {
593
908
  this.afterHandleHooks.length === 0 &&
594
909
  this.onErrorHooks.length === 0 &&
595
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;
596
932
  const fusedWeb = bare && this.aroundHooks.length === 0
597
933
  ? this.buildFusedWeb(handler, hasDecorations ? routeDecorations : undefined, isContextlessNoArgArrow(handler))
598
934
  : fusedQuery
599
935
  ? this.buildFusedQueryWeb(handler, hasDecorations ? routeDecorations : undefined, schema.query)
600
- : undefined;
936
+ : fusedBody
937
+ ? this.buildFusedBodyWeb(fusedBodyRunner)
938
+ : undefined;
601
939
  const contextless = bare && this.aroundHooks.length === 0 && isContextlessNoArgArrow(handler);
602
940
  // The body and query lanes finalize the handler's result themselves, so a contracted route takes
603
941
  // the general lifecycle lane instead - one place owns the check rather than three.
604
942
  const lane = bare
605
943
  ? "bare"
606
- : contracted === undefined &&
607
- schema?.body !== undefined &&
608
- schema.query === undefined &&
609
- schema.params === undefined &&
610
- this.derives.length === 0 &&
611
- this.beforeHandleHooks.length === 0 &&
612
- this.afterHandleHooks.length === 0 &&
613
- this.onErrorHooks.length === 0
944
+ : bodyOnly
614
945
  ? "body"
615
946
  : contracted === undefined &&
616
947
  schema?.body === undefined &&
@@ -622,8 +953,34 @@ export class Server {
622
953
  this.onErrorHooks.length === 0
623
954
  ? "query"
624
955
  : "lifecycle";
625
- const execution = this.compileExecutionPlan(lane, contextless, this.aroundHooks.length > 0, ledgered !== undefined, fusedWeb, fusedWeb === undefined ? undefined : fusedQuery ? "query" : "bare");
626
- 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 = {
627
984
  // (context: never) => unknown -> InternalHandler: the framework invokes it
628
985
  // with the concrete RawContext the typed handler expects, so this is sound.
629
986
  handler: handler,
@@ -637,6 +994,7 @@ export class Server {
637
994
  beforeHandle: [...this.beforeHandleHooks],
638
995
  afterHandle: [...this.afterHandleHooks],
639
996
  onError: [...this.onErrorHooks],
997
+ lifecycleHookLane,
640
998
  around: [...this.aroundHooks],
641
999
  execution,
642
1000
  };
@@ -677,17 +1035,17 @@ export class Server {
677
1035
  method,
678
1036
  path,
679
1037
  pattern,
680
- entry,
1038
+ entry: registeredEntry,
681
1039
  descriptor,
682
1040
  assurance: Object.freeze(routeAssurance),
683
1041
  };
684
1042
  }
685
1043
  /** Collapse route-invariant lifecycle decisions into one runner at registration. The request path
686
1044
  * performs no eligibility ladder: it supplies request state to this already-selected plan. */
687
- compileExecutionPlan(lane, contextless, hasAround, hasLedger, fusedWeb, fusedLane) {
1045
+ compileExecutionPlan(lane, contextless, hasAround, hasLedger, lifecycleLane, fusedWeb, fusedBody, fusedLane) {
688
1046
  if (contextless) {
689
- const run = (runtime, entry, source, params, search, signal, budget, platform, finalize, wrapResponse) => runtime.runContextlessBare(entry, source, params, search, signal, budget, platform, finalize, wrapResponse);
690
- 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 });
691
1049
  }
692
1050
  let inner;
693
1051
  switch (lane) {
@@ -701,13 +1059,30 @@ export class Server {
701
1059
  inner = (runtime, entry, _source, ctx, finalize, wrapResponse) => runtime.runQueryOnly(entry, ctx, finalize, wrapResponse);
702
1060
  break;
703
1061
  default:
704
- 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
+ }
705
1078
  }
706
1079
  const execute = hasAround
707
1080
  ? (runtime, entry, source, ctx, finalize, wrapResponse) => runtime.runWithAround(entry, ctx, () => inner(runtime, entry, source, ctx, finalize, wrapResponse), finalize, wrapResponse)
708
1081
  : inner;
709
- const run = (runtime, entry, source, params, search, signal, budget, platform, finalize, wrapResponse) => {
710
- 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);
711
1086
  let ledger;
712
1087
  // The runtime is always present when a route resolved a ledger (enforced at registration).
713
1088
  const ledgerRuntime = runtime.effectLedgerRuntime;
@@ -724,12 +1099,12 @@ export class Server {
724
1099
  }
725
1100
  return outcome;
726
1101
  };
727
- return Object.freeze({ run, fusedWeb, fusedLane });
1102
+ return Object.freeze({ run, fusedWeb, fusedBody, fusedLane });
728
1103
  }
729
1104
  /** The idempotency lane's bridge back into the normal matched lanes, resolved to a concrete Response
730
1105
  * (the lane buffers the body, then replays it through the route's real validation + handler). */
731
1106
  idempotencyRunLanes(buffered, platform, entry, params, search) {
732
- 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));
733
1108
  }
734
1109
  /** @internal Symbol-keyed install seam for the `responseContract()` plugin. Off the public typed surface. */
735
1110
  [INSTALL_RESPONSE_CONTRACT](runtime) {
@@ -800,8 +1175,23 @@ export class Server {
800
1175
  this.sseRuntime ??= source.sseRuntime;
801
1176
  this.wsRuntime ??= source.wsRuntime;
802
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
+ }
803
1186
  this.onResponseHooks.push(...source.onResponseHooks);
1187
+ this.onNodeResponseHooks.push(...source.onNodeResponseHooks);
1188
+ this.nodeResponseHooksComplete &&= source.nodeResponseHooksComplete;
804
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
+ }
805
1195
  this.globalAssurance.push(...source.globalAssurance);
806
1196
  this.mcpResourceList.push(...source.mcpResourceList);
807
1197
  this.mcpPromptList.push(...source.mcpPromptList);
@@ -815,14 +1205,19 @@ export class Server {
815
1205
  return route;
816
1206
  // Rebuild with the SAME builder that produced the closure - rebinding a query-fused route as
817
1207
  // bare would silently drop its validation.
818
- const fusedWeb = entry.execution.fusedLane === "query"
819
- ? this.buildFusedQueryWeb(entry.handler, entry.hasDecorations ? entry.decorations : undefined, entry.schema?.query)
820
- : 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));
821
1216
  return {
822
1217
  ...route,
823
1218
  entry: {
824
1219
  ...entry,
825
- execution: Object.freeze({ ...entry.execution, fusedWeb }),
1220
+ execution: Object.freeze({ ...entry.execution, fusedWeb, fusedBody }),
826
1221
  },
827
1222
  };
828
1223
  }
@@ -863,7 +1258,7 @@ export class Server {
863
1258
  // path, selected by the compiled execution plan), and an `async fetch` would wrap every such result in a redundant
864
1259
  // promise + microtask. Returning `Response | Promise<Response>` matches Web/edge handlers, while
865
1260
  // `await app.fetch(...)` continues to work exactly as before.
866
- 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);
867
1262
  if (this.onResponseHooks.length === 0 && this.onResponseFinalizedHooks.length === 0) {
868
1263
  return outcome;
869
1264
  }
@@ -881,7 +1276,7 @@ export class Server {
881
1276
  return this.admitGated(requestOf(source), () => this.fetchMatchedInner(source, entry, params));
882
1277
  }
883
1278
  fetchMatchedInner(source, entry, params) {
884
- 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);
885
1280
  if (this.onResponseHooks.length === 0 && this.onResponseFinalizedHooks.length === 0) {
886
1281
  return outcome;
887
1282
  }
@@ -931,20 +1326,28 @@ export class Server {
931
1326
  * Like {@link fetch}, but renders a plain-data result **without** building a Web `Response` - the
932
1327
  * `@nifrajs/node` adapter serializes the returned primitives straight to the socket, skipping the undici
933
1328
  * `Response` build + body drain (the bulk of the Node bridge cost, measured ≈4µs/req). A handler that
934
- * returns a `Response`, an error/short-circuit, or any registered `onResponse` hook falls back to the
935
- * full Web path (`{ kind: "response" }`), so behavior is identical - only the common JSON-data case is
936
- * faster. Same lifecycle as {@link fetch} (body cap, validation, hooks all run); only the final
937
- * 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.
938
1332
  */
939
1333
  resolveNode(req, platform) {
940
1334
  return this.resolveNodeSource(req, platform);
941
1335
  }
942
1336
  resolveNodeSource(source, platform, suppliedRuntime) {
943
- // onResponse hooks transform a Response, and the capacity gate wraps the Web response path - both
944
- // force the Web path here (the gated `fetchSource` admits/sheds/releases); wrap its result.
945
- if (this.onResponseHooks.length > 0 ||
946
- this.onResponseFinalizedHooks.length > 0 ||
947
- 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) {
948
1351
  const response = this.fetchSource(source, platform);
949
1352
  return response instanceof Promise
950
1353
  ? response.then((settled) => ({ kind: "response", response: settled }))
@@ -957,7 +1360,151 @@ export class Server {
957
1360
  if (runtime === undefined) {
958
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`).");
959
1362
  }
960
- 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
+ }
961
1508
  }
962
1509
  /**
963
1510
  * Resolve a WebSocket upgrade - the seam every serving adapter uses. Returns `pass` (not a WS
@@ -1066,8 +1613,42 @@ export class Server {
1066
1613
  if (this.onRequestHooks.length === 0) {
1067
1614
  return this.routeAndRun(source, resolved, finalize, wrapResponse, onTimeout, webFast);
1068
1615
  }
1616
+ if (!webFast && this.canUseNodeRequestHooks()) {
1617
+ return this.runWithNodeRequest(source, resolved, finalize, wrapResponse, onTimeout);
1618
+ }
1069
1619
  return this.runWithOnRequest(source, resolved, finalize, wrapResponse, onTimeout, webFast);
1070
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
+ }
1071
1652
  /**
1072
1653
  * onRequest short-circuit path. Synchronous as long as every hook returns synchronously (the
1073
1654
  * common case - e.g. CORS returning `undefined` for a non-preflight request): an `async` version
@@ -1078,6 +1659,7 @@ export class Server {
1078
1659
  runWithOnRequest(source, platform, finalize, wrapResponse, onTimeout, webFast) {
1079
1660
  const hooks = this.onRequestHooks;
1080
1661
  const originalRequest = requestOf(source);
1662
+ this.responseSources.set(source, originalRequest);
1081
1663
  let current = source;
1082
1664
  for (let i = 0; i < hooks.length; i++) {
1083
1665
  const outcome = hooks[i](requestOf(current), platform);
@@ -1119,6 +1701,16 @@ export class Server {
1119
1701
  return this.routeAndRun(current, platform, finalize, wrapResponse, onTimeout, webFast);
1120
1702
  }
1121
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
+ }
1122
1714
  const request = requestOf(source);
1123
1715
  const rewritten = this.responseRequests.get(request);
1124
1716
  if (rewritten === undefined)
@@ -1126,14 +1718,99 @@ export class Server {
1126
1718
  this.responseRequests.delete(request);
1127
1719
  return rewritten;
1128
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
+ }
1129
1765
  /**
1130
1766
  * Route → build context → run. Synchronous through to the handler for a **bare** route
1131
1767
  * (selected by its compiled plan), so a sync handler produces its result with zero promise allocations;
1132
1768
  * routes with validation/hooks keep the full async {@link runLifecycle}, unchanged.
1133
1769
  */
1134
1770
  routeAndRun(source, platform, finalize, wrapResponse, onTimeout, webFast) {
1135
- const url = source.urlParts ?? urlPartsOf(source.url);
1136
- 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);
1137
1814
  if (!match.found) {
1138
1815
  if (match.reason === "method-not-allowed") {
1139
1816
  return wrapResponse(jsonError(405, "method_not_allowed", { Allow: match.allowed.join(", ") }));
@@ -1146,7 +1823,7 @@ export class Server {
1146
1823
  if (params === null) {
1147
1824
  return wrapResponse(jsonError(400, "malformed_path"));
1148
1825
  }
1149
- 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);
1150
1827
  }
1151
1828
  /** Run a route that has already been matched by the runtime or Nifra's portable router. */
1152
1829
  runMatched(source, platform, entry, params, search, finalize, wrapResponse, onTimeout, webFast) {
@@ -1196,9 +1873,12 @@ export class Server {
1196
1873
  ? getUnboundedRequestBudget()
1197
1874
  : createRequestBudget({ deadline: admission.deadline, signal });
1198
1875
  const plan = entry.execution;
1876
+ const nativeContext = controller === undefined;
1199
1877
  const outcome = webFast && plan.fusedWeb !== undefined
1200
- ? plan.fusedWeb(source, params, search, signal, budget, platform, false)
1201
- : 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);
1202
1882
  // The request timeout only bounds work that is actually pending - a synchronous (bare) result is
1203
1883
  // already complete and can't time out, so it's returned as-is (no 503 race, no promise).
1204
1884
  if (controller !== undefined && outcome instanceof Promise) {
@@ -1270,11 +1950,13 @@ export class Server {
1270
1950
  * 500s), same respond semantics (the lifecycle parity suite pins it).
1271
1951
  */
1272
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.
1273
1955
  const logError = (err, ctx) => {
1274
1956
  if (err instanceof Response)
1275
- return err;
1957
+ return this.wrapWebResponse(err);
1276
1958
  this.logRequestError(err, ctx);
1277
- return jsonError(500, "internal_error");
1959
+ return this.wrapWebResponse(jsonError(500, "internal_error"));
1278
1960
  };
1279
1961
  if (contextless && decorations === undefined) {
1280
1962
  // `() => ...` can't observe the context - skip allocating one entirely (errors still build
@@ -1287,20 +1969,20 @@ export class Server {
1287
1969
  }
1288
1970
  catch (err) {
1289
1971
  return logError(err, nativeContext
1290
- ? RequestContext.native(source, params, this.maxBodyBytes)
1972
+ ? RequestContext.native(source, params, search, this.maxBodyBytes, platform)
1291
1973
  : new RequestContext(source, params, search, signal, budget, platform, this.maxBodyBytes));
1292
1974
  }
1293
1975
  if (result instanceof Promise) {
1294
- return result.then(fusedRespondNoSet, (err) => logError(err, nativeContext
1295
- ? 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)
1296
1978
  : new RequestContext(source, params, search, signal, budget, platform, this.maxBodyBytes)));
1297
1979
  }
1298
- return fusedRespondNoSet(result);
1980
+ return fusedRespondNoSet(result, this.responseBodyTag, this.staticResponseHeaders);
1299
1981
  };
1300
1982
  }
1301
1983
  return (source, params, search, signal, budget, platform, nativeContext) => {
1302
1984
  const ctx = nativeContext
1303
- ? RequestContext.native(source, params, this.maxBodyBytes)
1985
+ ? RequestContext.native(source, params, search, this.maxBodyBytes, platform)
1304
1986
  : new RequestContext(source, params, search, signal, budget, platform, this.maxBodyBytes);
1305
1987
  if (decorations !== undefined)
1306
1988
  Object.assign(ctx, decorations);
@@ -1312,11 +1994,87 @@ export class Server {
1312
1994
  return logError(err, ctx);
1313
1995
  }
1314
1996
  if (result instanceof Promise) {
1315
- 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));
1316
1998
  }
1317
- return fusedRespond(result, ctx);
1999
+ return fusedRespond(result, ctx, this.responseBodyTag, this.staticResponseHeaders);
1318
2000
  };
1319
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);
2060
+ }
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));
2071
+ };
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
+ }
1320
2078
  /** The fused Web lane for a route whose only lifecycle step is a query schema: parse + validate +
1321
2079
  * handler + respond in one closure, no lifecycle promise when the validator and handler are sync.
1322
2080
  * Eligibility is decided at registration (see `fusedQuery` in {@link register}); the semantics here
@@ -1325,11 +2083,13 @@ export class Server {
1325
2083
  * `Response` passes through, anything else logs and returns a flat 500, and an async validator
1326
2084
  * falls to a then-chain with the same steps. */
1327
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.
1328
2088
  const logError = (err, ctx) => {
1329
2089
  if (err instanceof Response)
1330
- return err;
2090
+ return this.wrapWebResponse(err);
1331
2091
  this.logRequestError(err, ctx);
1332
- return jsonError(500, "internal_error");
2092
+ return this.wrapWebResponse(jsonError(500, "internal_error"));
1333
2093
  };
1334
2094
  const runHandler = (ctx, value) => {
1335
2095
  ctx.query = value;
@@ -1341,13 +2101,13 @@ export class Server {
1341
2101
  return logError(err, ctx);
1342
2102
  }
1343
2103
  if (result instanceof Promise) {
1344
- 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));
1345
2105
  }
1346
- return fusedRespond(result, ctx);
2106
+ return fusedRespond(result, ctx, this.responseBodyTag, this.staticResponseHeaders);
1347
2107
  };
1348
2108
  return (source, params, search, signal, budget, platform, nativeContext) => {
1349
2109
  const ctx = nativeContext
1350
- ? RequestContext.native(source, params, this.maxBodyBytes)
2110
+ ? RequestContext.native(source, params, search, this.maxBodyBytes, platform)
1351
2111
  : new RequestContext(source, params, search, signal, budget, platform, this.maxBodyBytes);
1352
2112
  if (decorations !== undefined)
1353
2113
  Object.assign(ctx, decorations);
@@ -1360,11 +2120,11 @@ export class Server {
1360
2120
  }
1361
2121
  if (validation instanceof Promise) {
1362
2122
  return validation.then((settled) => settled.issues !== undefined
1363
- ? validationError(settled.issues)
2123
+ ? this.wrapWebResponse(validationError(settled.issues))
1364
2124
  : runHandler(ctx, settled.value), (err) => logError(err, ctx));
1365
2125
  }
1366
2126
  if (validation.issues !== undefined)
1367
- return validationError(validation.issues);
2127
+ return this.wrapWebResponse(validationError(validation.issues));
1368
2128
  return runHandler(ctx, validation.value);
1369
2129
  };
1370
2130
  }
@@ -1402,25 +2162,20 @@ export class Server {
1402
2162
  return dispatch(0);
1403
2163
  }
1404
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) {
1405
2170
  const contentType = headerOf(source, "content-type") ?? "";
1406
2171
  if (contentType !== "application/json" && !contentType.includes("application/json")) {
1407
2172
  if (isUrlEncodedForm(contentType)) {
1408
- // HTML form posts validate through the same schema as JSON (parity with Elysia/Hono).
1409
- // Off the JSON inline lane on purpose: forms are a fraction of API traffic, and the
1410
- // bounded form reader shares the streaming guard. JSON requests are untouched.
1411
- return readBoundedForm(source, this.maxBodyBytes).then((form) => {
1412
- if (form instanceof Response)
1413
- return wrapResponse(form);
1414
- return this.finishBodyOnly(entry, form, ctx, finalize, wrapResponse);
1415
- });
2173
+ return readBoundedForm(source, this.maxBodyBytes).then((form) => (form instanceof Response ? wrapResponse(form) : onParsed(form)), onError);
1416
2174
  }
1417
2175
  return Promise.resolve(wrapResponse(jsonError(415, "unsupported_media_type")));
1418
2176
  }
1419
- // Inline fast path (profiled): a framed, in-cap, non-chunked body parses with the native
1420
- // `req.json()` directly - one promise, one `.then` closure per request. The generic
1421
- // `readBoundedJson` (an extra async-fn frame + per-request `finish`/`applyValidation`
1422
- // closures) is kept for the chunked / length-less / oversized cases it exists for.
1423
- // 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.
1424
2179
  const declared = headerOf(source, "content-length");
1425
2180
  if (declared !== null) {
1426
2181
  const length = parseContentLength(declared);
@@ -1431,21 +2186,14 @@ export class Server {
1431
2186
  return Promise.resolve(wrapResponse(jsonError(413, "payload_too_large")));
1432
2187
  }
1433
2188
  if (headerOf(source, "transfer-encoding") === null) {
1434
- return source.json().then((parsed) => this.finishBodyOnly(entry, parsed, ctx, finalize, wrapResponse),
1435
- // Native json() rejection = malformed JSON; anything past parse goes through
1436
- // finishBodyOnly's own error handling.
1437
- () => wrapResponse(jsonError(400, "invalid_json")));
2189
+ return source.json().then(onParsed, () => wrapResponse(jsonError(400, "invalid_json")));
1438
2190
  }
1439
2191
  }
1440
2192
  try {
1441
- return this.readBoundedJson(source).then((parsed) => {
1442
- if (parsed instanceof Response)
1443
- return wrapResponse(parsed);
1444
- return this.finishBodyOnly(entry, parsed, ctx, finalize, wrapResponse);
1445
- }, (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse));
2193
+ return this.readBoundedJson(source).then((parsed) => (parsed instanceof Response ? wrapResponse(parsed) : onParsed(parsed)), onError);
1446
2194
  }
1447
2195
  catch (err) {
1448
- return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse);
2196
+ return Promise.resolve(onError(err));
1449
2197
  }
1450
2198
  }
1451
2199
  /** Validate + run the handler for the bodyOnly path - shared by the inline fast path and the
@@ -1626,7 +2374,11 @@ export class Server {
1626
2374
  throw error;
1627
2375
  });
1628
2376
  }
1629
- 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);
1630
2382
  }
1631
2383
  /** Notify terminal observers in order while isolating both sync and async failures. */
1632
2384
  notifyResponseFinalized(outcome, req) {
@@ -1689,100 +2441,314 @@ export class Server {
1689
2441
  * Generic over the render: a success value goes through `finalize(result, set)` (a Web `Response`, or
1690
2442
  * node-direct primitives); an early/error `Response` (validation, thrown, 500) through `wrapResponse`.
1691
2443
  */
1692
- 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
+ }
1693
2575
  try {
1694
- if (entry.schema?.params !== undefined) {
1695
- // Path params arrive as strings from routing; validate (and coerce) them at the boundary, before
1696
- // the body/query, so a malformed `:id` is a 422 before any work runs. Raw `.validate` (no wrapper
1697
- // alloc), same recovery-hook path as body/query.
1698
- const validation = entry.schema.params["~standard"].validate(ctx.params);
1699
- const result = validation instanceof Promise ? await validation : validation;
1700
- const paramsError = await this.applyLifecycleValidation(entry, result, ctx, "params");
1701
- if (paramsError !== undefined)
1702
- return wrapResponse(paramsError);
1703
- }
1704
- if (entry.schema?.body !== undefined) {
1705
- const bodyError = await this.readAndValidateBody(source, entry, ctx);
1706
- if (bodyError !== undefined)
1707
- return wrapResponse(bodyError);
1708
- }
1709
- if (entry.schema?.query !== undefined) {
1710
- // Repeated query keys promote to `string[]` (so an array schema validates `?a=1&a=2`); a
1711
- // single occurrence stays a string. Raw `.validate` (read `.issues`/`.value`) skips the
1712
- // validateStandard wrapper alloc.
1713
- const validation = entry.schema.query["~standard"].validate(queryObjectOf(ctx[CONTEXT_SEARCH]));
1714
- const result = validation instanceof Promise ? await validation : validation;
1715
- const queryError = await this.applyLifecycleValidation(entry, result, ctx, "query");
1716
- if (queryError !== undefined)
1717
- return wrapResponse(queryError);
1718
- }
1719
- // Context extensions: static decorations, then per-request derives.
1720
- // Each hook below only awaits when it actually returns a Promise - a sync hook
1721
- // skips the microtask tick (a fast-path; ~64 ns/hook).
1722
2576
  if (entry.hasDecorations)
1723
- Object.assign(ctx, entry.decorations); // skip the no-op on bare routes
1724
- // The `.length` guards skip iterator setup for the common no-hook route (most
1725
- // routes have neither derives nor before/after hooks); a hooked route pays only
1726
- // the length check.
1727
- if (entry.derives.length > 0) {
1728
- for (const derive of entry.derives) {
1729
- const extension = derive(ctx);
1730
- 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);
1731
2582
  }
2583
+ Object.assign(ctx, extension);
1732
2584
  }
1733
- // beforeHandle: a non-undefined return short-circuits, skipping the handler.
1734
- if (entry.beforeHandle.length > 0) {
1735
- for (const hook of entry.beforeHandle) {
1736
- const outcome = hook(ctx);
1737
- const early = outcome instanceof Promise ? await outcome : outcome;
1738
- if (early !== undefined)
1739
- 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);
1740
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);
1741
2614
  }
1742
- const handlerOutput = entry.handler(ctx);
1743
- let result = handlerOutput instanceof Promise ? await handlerOutput : handlerOutput;
1744
- // afterHandle: transform the result before serialization.
1745
- if (entry.afterHandle.length > 0) {
1746
- for (const hook of entry.afterHandle) {
1747
- const transformed = hook(result, ctx);
1748
- 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);
1749
2697
  }
2698
+ result = transformed;
1750
2699
  }
1751
- // The response contract is checked LAST, after afterHandle, so it holds what actually ships
1752
- // rather than what the handler happened to return. Only reached by a contracted route: the
1753
- // 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 {
1754
2721
  const contract = entry.responseContract;
1755
- if (contract !== undefined) {
1756
- const checked = contract.runtime.check(contract.schema, result);
1757
- const outcome = checked instanceof Promise ? await checked : checked;
1758
- if (outcome.kind === "violation") {
1759
- // The handler broke a contract it declared itself, so this is a server fault, not the
1760
- // caller's. Fail closed: the undeclared payload is never sent, and the detail goes to the
1761
- // (redacting) logger rather than to the client.
1762
- this.logger.error("response contract violation", {
1763
- method: ctx.req.method,
1764
- path: pathnameOf(ctx.req.url),
1765
- // `detail`, not `message`: the logger uses `message` for its own first argument, so a
1766
- // field of that name is silently overwritten and the diagnosis disappears.
1767
- detail: outcome.message,
1768
- });
1769
- return wrapResponse(jsonError(500, "internal_error"));
1770
- }
1771
- if (outcome.kind === "warn") {
1772
- this.logger.warn("response contract", {
1773
- method: ctx.req.method,
1774
- path: pathnameOf(ctx.req.url),
1775
- detail: outcome.message,
1776
- });
1777
- }
1778
- 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));
1779
2727
  }
1780
- return finalize(result, responseSet(ctx));
2728
+ return this.finishContractOutcome(ctx, finalize, wrapResponse, checked);
1781
2729
  }
1782
2730
  catch (err) {
1783
2731
  return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse);
1784
2732
  }
1785
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
+ }
1786
2752
  async handleLifecycleError(entry, err, ctx, finalize, wrapResponse) {
1787
2753
  // A *thrown* Response is deliberate control flow, not an error - a guard throws a redirect/401,
1788
2754
  // an action throws an error page. Return it as-is (Remix/SvelteKit semantics); don't run onError
@@ -1842,7 +2808,7 @@ export class Server {
1842
2808
  }
1843
2809
  /** Apply validation and its recovery hook on the generic lifecycle lane. Recovery is completed
1844
2810
  * before derives/beforeHandle run, matching the body-only and query-only execution lanes. */
1845
- async applyLifecycleValidation(entry, result, ctx, kind) {
2811
+ applyLifecycleValidation(entry, result, ctx, kind) {
1846
2812
  const assign = (value) => {
1847
2813
  if (kind === "body")
1848
2814
  ctx.body = value;
@@ -1859,9 +2825,14 @@ export class Server {
1859
2825
  if (hook === undefined)
1860
2826
  return validationError(result.issues);
1861
2827
  const attempted = hook(result.issues, ctx, kind);
1862
- 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) {
1863
2834
  if (recovery === undefined)
1864
- return validationError(result.issues);
2835
+ return validationError(issues);
1865
2836
  if (recovery instanceof Response)
1866
2837
  return recovery;
1867
2838
  const schema = kind === "body"
@@ -1870,10 +2841,17 @@ export class Server {
1870
2841
  ? entry.schema?.query
1871
2842
  : entry.schema?.params;
1872
2843
  const retried = schema["~standard"].validate(recovery);
1873
- const settled = retried instanceof Promise ? await retried : retried;
1874
- if (settled.issues !== undefined)
1875
- return validationError(settled.issues);
1876
- 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);
1877
2855
  return undefined;
1878
2856
  }
1879
2857
  /**
@@ -1971,7 +2949,18 @@ export class Server {
1971
2949
  methods[method] = this.compileBunNativeHandler(entry, paramNames, fused, unboundedSignal, unboundedBudget);
1972
2950
  count += 1;
1973
2951
  }
1974
- return count === 0 ? undefined : routes;
2952
+ if (count === 0)
2953
+ return undefined;
2954
+ // RFC 9110 §9.3.2: a GET route answers HEAD with identical status + headers (Bun strips the
2955
+ // body on the wire). Alias the compiled GET handler under HEAD so HEAD stays on the native
2956
+ // lane instead of falling through to the portable dispatcher; the router applies the same
2957
+ // fallback there, so both lanes agree. An explicit HEAD registration wins via the ??=.
2958
+ for (const path of Object.keys(routes)) {
2959
+ const methods = routes[path];
2960
+ if (methods.GET !== undefined)
2961
+ methods.HEAD ??= methods.GET;
2962
+ }
2963
+ return routes;
1975
2964
  }
1976
2965
  /**
1977
2966
  * Start a `Bun.serve` instance bound to `port` (use `0` for an ephemeral port).