@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
@@ -28,6 +28,7 @@ import {
28
28
  import { type CatalogRoute, RouteCatalog } from "../internal/route-catalog.ts"
29
29
  import type {
30
30
  ContextRouteRunner,
31
+ FusedBodyRunner,
31
32
  FusedWebRunner,
32
33
  InternalHandler,
33
34
  RawAfterHandle,
@@ -53,8 +54,19 @@ import { assertByteLimit, parseContentLength } from "./body.ts"
53
54
  import { type ClientIpTrust, resolveClientIp } from "./client-ip.ts"
54
55
  import type { Context, Platform, ResponseControls, RouteSchema } from "./context.ts"
55
56
  import { jsonError, pathnameOf, type UrlParts, urlPartsOf } from "./http.ts"
56
- import type { NodeServeOutcome } from "./node-outcome.ts"
57
- import type { NodeOutcomeRuntime } from "./node-outcome-hook.ts"
57
+ import { type NodeServeOutcome, withStaticNodeHeaders } from "./node-outcome.ts"
58
+ import {
59
+ type NodeOutcomeRuntime,
60
+ type NodeRequestContext,
61
+ type NodeRequestHook,
62
+ type NodeResponseContext,
63
+ type NodeResponseHook,
64
+ type ResponseBodyHook,
65
+ type ResponseBodyReplacement,
66
+ type ResponseHeadersHook,
67
+ type ResponseHeadersView,
68
+ recordHeadersView,
69
+ } from "./node-outcome-hook.ts"
58
70
  import {
59
71
  isUrlEncodedForm,
60
72
  type QueryValue,
@@ -63,7 +75,18 @@ import {
63
75
  searchOf,
64
76
  } from "./query.ts"
65
77
  import { RequestContext, readBoundedJsonSource } from "./request-context.ts"
66
- import { fusedRespond, fusedRespondNoSet, toResponse } from "./respond.ts"
78
+ import {
79
+ applyStaticResponseHeaders,
80
+ buildStaticResponseHeaders,
81
+ fusedRespond,
82
+ fusedRespondNoSet,
83
+ knownMutableHeaders,
84
+ markTaggedResponse,
85
+ rememberMutableHeaders,
86
+ taggedResponseBody,
87
+ taggedResponseOwner,
88
+ toResponse,
89
+ } from "./respond.ts"
67
90
  // Type-only: erased, so the kernel never pulls the lane's implementation into a bundle that does not
68
91
  // install the plugin. The value side arrives through the symbol-keyed install seam.
69
92
  import type { ResponseContractRuntime } from "./response-contract-lane.ts"
@@ -77,10 +100,161 @@ import {
77
100
  headerOf,
78
101
  requestOf,
79
102
  } from "./runtime-core.ts"
103
+ import { normalizeStaticResponseHeaders, type StaticResponseHeaders } from "./static-headers.ts"
80
104
 
81
105
  // NodeServeOutcome (the nifra<->node bridge render form) now lives in `./node-outcome.ts`; re-exported
82
106
  // so existing importers keep resolving it from the server module.
83
- export type { NodeServeOutcome }
107
+ export type {
108
+ NodeRequestContext,
109
+ NodeRequestHook,
110
+ NodeResponseContext,
111
+ NodeResponseHook,
112
+ NodeServeOutcome,
113
+ ResponseBodyHook,
114
+ ResponseBodyReplacement,
115
+ ResponseHeadersHook,
116
+ ResponseHeadersView,
117
+ }
118
+
119
+ function isBodylessStatus(status: number): boolean {
120
+ return status === 204 || status === 205 || status === 304
121
+ }
122
+
123
+ /** Apply a body hook's return to the native response context (bytes, or a structured replacement). */
124
+ function applyBodyReplacement(
125
+ response: NodeResponseContext,
126
+ replaced: string | Uint8Array | ResponseBodyReplacement | undefined,
127
+ ): void {
128
+ if (replaced === undefined) return
129
+ if (typeof replaced === "string" || replaced instanceof Uint8Array) {
130
+ response.body = isBodylessStatus(response.status) ? null : replaced
131
+ return
132
+ }
133
+ if (replaced.body !== undefined) response.body = replaced.body
134
+ if (replaced.status !== undefined) response.status = replaced.status
135
+ if (isBodylessStatus(response.status)) response.body = null
136
+ }
137
+
138
+ /** Swap a tagged Response's body for a hook's replacement, re-tagging so later body hooks (and the
139
+ * Node fallback's direct writer) see the new bytes; explicit lengths are dropped so framing is
140
+ * re-derived from what actually ships. */
141
+ function withReplacedBody(
142
+ response: Response,
143
+ replaced: string | Uint8Array | ResponseBodyReplacement | undefined,
144
+ ): Response {
145
+ if (replaced === undefined) return response
146
+ const originalBody = taggedResponseBody(response)
147
+ let body: string | Uint8Array | null
148
+ let status = response.status
149
+ if (typeof replaced === "string" || replaced instanceof Uint8Array) {
150
+ body = replaced
151
+ } else {
152
+ body = replaced.body !== undefined ? replaced.body : (originalBody ?? null)
153
+ if (replaced.status !== undefined) status = replaced.status
154
+ if (body === (originalBody ?? null) && status === response.status) {
155
+ return response
156
+ }
157
+ }
158
+ if (isBodylessStatus(status)) body = null
159
+ const headers = new Headers(response.headers)
160
+ headers.delete("content-length")
161
+ const next = new Response(body as ConstructorParameters<typeof Response>[0], {
162
+ status,
163
+ headers,
164
+ })
165
+ if (body !== null) markTaggedResponse(next, body, taggedResponseOwner(response))
166
+ return next
167
+ }
168
+
169
+ const HEADER_MUTABILITY_PROBE = "x-nifra-header-probe"
170
+ const GUARDED_RESPONSE_HEADERS = new WeakSet<Headers>()
171
+
172
+ /**
173
+ * Detect a guarded Web Headers object before invoking user code. Retrying after catching any
174
+ * TypeError is observably wrong: user hooks are allowed to throw TypeError themselves, and a retry
175
+ * runs those hooks twice. The hot path never reaches the probe: every framework-constructed
176
+ * Response stamps its headers as known-mutable at construction (see respond.ts), so only a
177
+ * handler-returned foreign `Response` - the case that can actually be guarded - pays it, once per
178
+ * headers object. The probe uses a valid private header name and restores a pre-existing value, so
179
+ * the fallback is limited to the actual immutable/guarded-header case.
180
+ */
181
+ function hasMutableResponseHeaders(headers: Headers): boolean {
182
+ if (knownMutableHeaders(headers)) return true
183
+ if (GUARDED_RESPONSE_HEADERS.has(headers)) return false
184
+ let previous: string | null = null
185
+ try {
186
+ previous = headers.get(HEADER_MUTABILITY_PROBE)
187
+ headers.set(HEADER_MUTABILITY_PROBE, "1")
188
+ if (previous === null) headers.delete(HEADER_MUTABILITY_PROBE)
189
+ else headers.set(HEADER_MUTABILITY_PROBE, previous)
190
+ rememberMutableHeaders(headers)
191
+ return true
192
+ } catch {
193
+ try {
194
+ if (previous === null) headers.delete(HEADER_MUTABILITY_PROBE)
195
+ else headers.set(HEADER_MUTABILITY_PROBE, previous)
196
+ } catch {
197
+ // The guarded object rejected the cleanup too; the clone below is authoritative.
198
+ }
199
+ GUARDED_RESPONSE_HEADERS.add(headers)
200
+ return false
201
+ }
202
+ }
203
+
204
+ /** Adapt a portable {@link ResponseBodyHook} into the Web `onResponse` walk. Only a Response
205
+ * carrying the framework-buffered body tag participates - a raw or streamed Response is skipped by
206
+ * contract, never drained. */
207
+ function webResponseBodyHook(
208
+ fn: ResponseBodyHook,
209
+ owners: ReadonlySet<object>,
210
+ ): (response: Response, req: Request) => MaybePromise<Response> {
211
+ return (response, req) => {
212
+ const body = taggedResponseBody(response, owners)
213
+ if (body === undefined) return response
214
+ const out = fn(body, response.headers, webRequestView(req), response.status)
215
+ if (out instanceof Promise) return out.then((replaced) => withReplacedBody(response, replaced))
216
+ return withReplacedBody(response, out)
217
+ }
218
+ }
219
+
220
+ /** Minimal request view over a Web `Request` for portable header hooks on the Web serving paths. */
221
+ function webRequestView(req: Request): NodeRequestContext {
222
+ return { method: req.method, url: req.url, header: (name) => req.headers.get(name) }
223
+ }
224
+
225
+ /**
226
+ * Adapt a portable {@link ResponseHeadersHook} into the Web `onResponse` walk: run it against the
227
+ * response's own `Headers` in place (no clone). A response whose headers are GUARDED (a raw
228
+ * `fetch()`ed Response returned by a handler) throws on the FIRST mutation, so nothing was applied
229
+ * yet - rerun once against a mutable copy. Async hooks should perform their mutations before their
230
+ * first await for the guard fallback to cover them.
231
+ */
232
+ function webResponseHeadersHook(
233
+ fn: ResponseHeadersHook,
234
+ ): (response: Response, req: Request) => MaybePromise<Response> {
235
+ return (response, req) => {
236
+ const view = webRequestView(req)
237
+ if (!hasMutableResponseHeaders(response.headers)) {
238
+ const clone = new Response(response.body, response)
239
+ const out = fn(clone.headers, view, clone.status)
240
+ return out instanceof Promise ? out.then(() => clone) : clone
241
+ }
242
+ const out = fn(response.headers, view, response.status)
243
+ return out instanceof Promise ? out.then(() => response) : response
244
+ }
245
+ }
246
+
247
+ /** Adapt a raw-response fallback hook. Tagged framework payloads already ran through the body tier,
248
+ * while untagged Responses include streams, proxied fetches, and framework-generated error responses. */
249
+ function webResponseRawHook(
250
+ fn: (response: Response, req: Request) => MaybePromise<Response>,
251
+ owners: ReadonlySet<object>,
252
+ ): (response: Response, req: Request) => MaybePromise<Response> {
253
+ return (response, req) => {
254
+ if (taggedResponseBody(response, owners) !== undefined) return response
255
+ return fn(response, req)
256
+ }
257
+ }
84
258
 
85
259
  import type { IdempotencyRuntime } from "./idempotency-lane.ts"
86
260
  import {
@@ -439,9 +613,35 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
439
613
  private readonly onErrorHooks: RawErrorHandler[]
440
614
  private readonly aroundHooks: RawAround[]
441
615
  private readonly onRequestHooks: RawOnRequest[]
616
+ private readonly onNodeRequestHooks: Array<NodeRequestHook | undefined>
617
+ /** Registration-time eligibility for the Node request twin lane. */
618
+ private nodeRequestHooksComplete: boolean
442
619
  private readonly onResponseHooks: RawOnResponse[]
620
+ private readonly onNodeResponseHooks: Array<NodeResponseHook | undefined>
621
+ /** Registration-time eligibility for the Node response twin lane. */
622
+ private nodeResponseHooksComplete: boolean
443
623
  private readonly onResponseFinalizedHooks: RawOnResponseFinalized[]
624
+ /**
625
+ * Statically declared response headers, merged and prebuilt at registration; `undefined` (the
626
+ * default) leaves every render path in its original shape. These are NOT response hooks - they are
627
+ * folded into response construction - so declaring them keeps the fused native lanes an
628
+ * `onResponse` hook would have disabled.
629
+ */
630
+ private staticResponseHeaders: StaticResponseHeaders | undefined
631
+ /** `wrapResponse` for the Web lanes: identity until static headers exist to fold into the
632
+ * framework's own error/404/timeout renders, which are built outside the header init. */
633
+ private wrapWebResponse: (response: Response) => Response
634
+ private webResponseTimeout: () => Response
635
+ /** Body/raw response hooks need a framework-payload marker; keep that decision per app. */
636
+ private responseBodyTag: object | undefined
637
+ private readonly responseBodyOwners: Set<object>
638
+ private readonly finalizeResponse = (result: unknown, set: CtxSet): Response =>
639
+ toResponse(result as HandlerResult, set, this.responseBodyTag, this.staticResponseHeaders)
444
640
  private readonly responseRequests: WeakMap<Request, Request>
641
+ /** Original source → request observed by generic request hooks, including in-place mutations. */
642
+ private readonly responseSources: WeakMap<object, Request>
643
+ /** Memoized NodeRequestContext per plain-`Request` source - see {@link nodeRequestContextOf}. */
644
+ private readonly nodeContexts: WeakMap<object, NodeRequestContext>
445
645
  /** Names of plugins/middleware already applied via `use` - for idempotent dedupe. */
446
646
  private readonly appliedPlugins: Set<string>
447
647
  /** Order-scoped evidence captured by routes registered after an assured plugin. */
@@ -499,9 +699,20 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
499
699
  this.onErrorHooks = []
500
700
  this.aroundHooks = []
501
701
  this.onRequestHooks = []
702
+ this.onNodeRequestHooks = []
703
+ this.nodeRequestHooksComplete = true
502
704
  this.onResponseHooks = []
705
+ this.onNodeResponseHooks = []
706
+ this.nodeResponseHooksComplete = true
503
707
  this.onResponseFinalizedHooks = []
708
+ this.staticResponseHeaders = undefined
709
+ this.wrapWebResponse = IDENTITY_RESPONSE
710
+ this.webResponseTimeout = RESPONSE_TIMEOUT
711
+ this.responseBodyTag = undefined
712
+ this.responseBodyOwners = new Set()
504
713
  this.responseRequests = new WeakMap()
714
+ this.responseSources = new WeakMap()
715
+ this.nodeContexts = new WeakMap()
505
716
  this.appliedPlugins = new Set()
506
717
  this.activeAssurance = []
507
718
  this.globalAssurance = []
@@ -541,6 +752,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
541
752
  ): this {
542
753
  this.assertConfigurable("onRequest()")
543
754
  this.onRequestHooks.push(fn as RawOnRequest)
755
+ this.onNodeRequestHooks.push(undefined)
756
+ this.nodeRequestHooksComplete = false
544
757
  return this
545
758
  }
546
759
 
@@ -614,6 +827,122 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
614
827
  onResponse(fn: (response: Response, req: Request) => MaybePromise<Response>): this {
615
828
  this.assertConfigurable("onResponse()")
616
829
  this.onResponseHooks.push(fn)
830
+ this.onNodeResponseHooks.push(undefined)
831
+ this.nodeResponseHooksComplete = false
832
+ return this
833
+ }
834
+
835
+ /**
836
+ * Declare response headers with NO per-request decision behind them - and pay nothing for them on
837
+ * the request path.
838
+ *
839
+ * These are not a hook. Because the values are known at wire-up, they are folded into response
840
+ * construction (one prebuilt init for JSON renders, one record merge where the request set its own
841
+ * headers), so an app whose only response middleware is static keeps every fused and native lane -
842
+ * `onResponse`/`onResponseHeaders` disable Bun's fused native routes and, for a full `onResponse`,
843
+ * the Node direct writer. They apply to EVERY response, exactly as a response hook would: success,
844
+ * error, 404/405, timeout, and short-circuit alike.
845
+ *
846
+ * app.responseHeaders({ "x-frame-options": "DENY", "referrer-policy": "no-referrer" })
847
+ *
848
+ * They are DEFAULTS: a value the request itself produced (`c.set.headers`, or a response hook)
849
+ * wins, whatever casing it used. Names are lowercased once here; a non-string value, an invalid
850
+ * name, `__proto__`, or a name the render owns (`content-type`, `content-length`,
851
+ * `transfer-encoding`, `set-cookie`) throws a `TypeError` at wire-up.
852
+ *
853
+ * ORDERING: declarations made before any response hook fold into one static record. One made AFTER
854
+ * a response hook cannot - the hook may already have written that name and must keep winning - so
855
+ * it registers as an ordinary `onResponseHeaders` hook instead, preserving registration order at
856
+ * the cost of the static tier's speed. Declare static headers first.
857
+ */
858
+ responseHeaders(record: Readonly<Record<string, string>>): this {
859
+ this.assertConfigurable("responseHeaders()")
860
+ return this.addStaticResponseHeaders(normalizeStaticResponseHeaders(record))
861
+ }
862
+
863
+ /** Wire an already-validated lowercase record into the static tier, or - once a dynamic response
864
+ * hook owns part of the header state - into a hook that runs in the right order. */
865
+ private addStaticResponseHeaders(record: Record<string, string>): this {
866
+ if (this.onResponseHooks.length > 0) {
867
+ return this.onResponseHeaders((headers) => {
868
+ for (const name of Object.keys(record)) {
869
+ if (!headers.has(name)) headers.set(name, record[name] as string)
870
+ }
871
+ })
872
+ }
873
+ const merged =
874
+ this.staticResponseHeaders === undefined
875
+ ? record
876
+ : { ...this.staticResponseHeaders.record, ...record }
877
+ this.staticResponseHeaders = buildStaticResponseHeaders(merged)
878
+ const statics = this.staticResponseHeaders
879
+ this.wrapWebResponse = (response) => applyStaticResponseHeaders(response, statics)
880
+ this.webResponseTimeout = () => applyStaticResponseHeaders(RESPONSE_TIMEOUT(), statics)
881
+ return this
882
+ }
883
+
884
+ private enableResponseBodyTagging(): object {
885
+ if (this.responseBodyTag === undefined) {
886
+ this.responseBodyTag = Object.freeze({})
887
+ this.responseBodyOwners.add(this.responseBodyTag)
888
+ }
889
+ return this.responseBodyTag
890
+ }
891
+
892
+ /**
893
+ * Register a response transform for raw/streamed Responses. Framework-serialized payloads stay on
894
+ * the body tier; this hook is only entered for untagged Responses (streams, proxied fetches, and
895
+ * framework-generated error responses). A no-op native twin keeps buffered JSON on the direct lane.
896
+ */
897
+ onResponseRaw(fn: (response: Response, req: Request) => MaybePromise<Response>): this {
898
+ this.assertConfigurable("onResponseRaw()")
899
+ this.enableResponseBodyTagging()
900
+ this.onResponseHooks.push(webResponseRawHook(fn, this.responseBodyOwners))
901
+ this.onNodeResponseHooks.push(() => undefined)
902
+ return this
903
+ }
904
+
905
+ /**
906
+ * Register a PORTABLE header-only response hook - one implementation, fast on every runtime.
907
+ *
908
+ * The hook receives a mutable case-insensitive header view, a minimal request view, and the
909
+ * status. On the Web serving paths it runs inside the normal `onResponse` walk against the
910
+ * response's own `Headers` (mutating in place - no clone). On Node it self-pairs as a native
911
+ * response hook against the outcome's plain header record, so registering one NEVER forces the
912
+ * Node adapter off its direct socket writer the way a full `onResponse(res: Response)` hook does.
913
+ * Prefer this over `onResponse` whenever the middleware only reads/writes headers.
914
+ */
915
+ onResponseHeaders(fn: ResponseHeadersHook): this {
916
+ this.assertConfigurable("onResponseHeaders()")
917
+ this.onResponseHooks.push(webResponseHeadersHook(fn))
918
+ this.onNodeResponseHooks.push((response, req) =>
919
+ fn(recordHeadersView(response), req, response.status),
920
+ )
921
+ return this
922
+ }
923
+
924
+ /**
925
+ * Register a PORTABLE post-serialization body hook - the payload tier. The hook receives the
926
+ * FINAL framework-serialized bytes (plus the mutable header view and status) and may return
927
+ * replacement bytes; `undefined` keeps the body. On the Node direct writer the bytes come
928
+ * straight off the outcome record; on the Web serving paths they ride the framework-built
929
+ * Response as a tag, so no body stream is ever drained on any runtime. A handler-returned raw
930
+ * `Response` (proxied fetch, SSE, streamed SSR) is skipped by contract - transforming those is
931
+ * what the full `onResponse` hook is for.
932
+ */
933
+ onResponseBody(fn: ResponseBodyHook): this {
934
+ this.assertConfigurable("onResponseBody()")
935
+ this.enableResponseBodyTagging()
936
+ this.onResponseHooks.push(webResponseBodyHook(fn, this.responseBodyOwners))
937
+ this.onNodeResponseHooks.push((response, req) => {
938
+ const body = response.body
939
+ if (body === null) return undefined
940
+ const out = fn(body, recordHeadersView(response), req, response.status)
941
+ if (out instanceof Promise)
942
+ return out.then((replaced) => applyBodyReplacement(response, replaced))
943
+ applyBodyReplacement(response, out)
944
+ return undefined
945
+ })
617
946
  return this
618
947
  }
619
948
 
@@ -682,11 +1011,31 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
682
1011
  }
683
1012
  this.globalAssurance.push(...evidence.filter((item) => item.scope === "global"))
684
1013
  this.activeAssurance.push(...evidence.filter((item) => item.scope === "subsequent"))
685
- if (arg.onRequest !== undefined) this.onRequest(arg.onRequest)
1014
+ if (arg.onRequest !== undefined) {
1015
+ this.assertConfigurable("onRequest()")
1016
+ this.onRequestHooks.push(arg.onRequest as RawOnRequest)
1017
+ this.onNodeRequestHooks.push(arg.onNodeRequest)
1018
+ if (arg.onNodeRequest === undefined) this.nodeRequestHooksComplete = false
1019
+ } else if (arg.onNodeRequest !== undefined) {
1020
+ throw new TypeError("onNodeRequest() requires a paired onRequest() hook")
1021
+ }
686
1022
  if (arg.around !== undefined) this.around(arg.around)
687
1023
  if (arg.beforeHandle !== undefined) this.beforeHandle(arg.beforeHandle)
688
1024
  if (arg.afterHandle !== undefined) this.afterHandle(arg.afterHandle)
689
- if (arg.onResponse !== undefined) this.onResponse(arg.onResponse)
1025
+ if (arg.onResponse !== undefined) {
1026
+ this.assertConfigurable("onResponse()")
1027
+ this.onResponseHooks.push(arg.onResponse)
1028
+ this.onNodeResponseHooks.push(arg.onNodeResponse)
1029
+ if (arg.onNodeResponse === undefined) this.nodeResponseHooksComplete = false
1030
+ } else if (arg.onNodeResponse !== undefined) {
1031
+ throw new TypeError("onNodeResponse() requires a paired onResponse() hook")
1032
+ }
1033
+ // Before the bundle's own hooks: a bundle declaring both means its static values are the
1034
+ // defaults its hook may then override, which is the order a single bundle reads in.
1035
+ if (arg.responseHeaders !== undefined) this.responseHeaders(arg.responseHeaders)
1036
+ if (arg.onResponseHeaders !== undefined) this.onResponseHeaders(arg.onResponseHeaders)
1037
+ if (arg.onResponseBody !== undefined) this.onResponseBody(arg.onResponseBody)
1038
+ if (arg.onResponseRaw !== undefined) this.onResponseRaw(arg.onResponseRaw)
690
1039
  if (arg.onResponseFinalized !== undefined) this.onResponseFinalized(arg.onResponseFinalized)
691
1040
  if (arg.onError !== undefined) this.onError(arg.onError)
692
1041
  return this
@@ -1085,7 +1434,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1085
1434
  )
1086
1435
  // A checked response schema needs the handler's VALUE before it becomes bytes, and the fused and
1087
1436
  // native lanes exist precisely to skip that step. So a contracted route leaves them - the same
1088
- // trade an idempotent route makes above, and the reason this is opt-in per server.
1437
+ // structural trade an idempotent route makes above. The check itself is not the cost: a compiled
1438
+ // validator measures ~100ns/response, within benchmark noise on any route these lanes already
1439
+ // exclude (middleware, derives, lifecycle hooks).
1089
1440
  const runtime = this.responseContractRuntime
1090
1441
  const contracted =
1091
1442
  runtime !== undefined && schema?.response !== undefined
@@ -1123,6 +1474,33 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1123
1474
  this.afterHandleHooks.length === 0 &&
1124
1475
  this.onErrorHooks.length === 0 &&
1125
1476
  this.aroundHooks.length === 0
1477
+ const bodyOnly =
1478
+ contracted === undefined &&
1479
+ schema?.body !== undefined &&
1480
+ schema.query === undefined &&
1481
+ schema.params === undefined &&
1482
+ this.derives.length === 0 &&
1483
+ this.beforeHandleHooks.length === 0 &&
1484
+ this.afterHandleHooks.length === 0 &&
1485
+ this.onErrorHooks.length === 0
1486
+ const fusedBody =
1487
+ !bare &&
1488
+ bodyOnly &&
1489
+ schema.onValidationError === undefined &&
1490
+ this.defaultOnValidationError === undefined &&
1491
+ idempotent === undefined &&
1492
+ ledgered === undefined &&
1493
+ this.aroundHooks.length === 0
1494
+ // Body-only routes still need an async body read, but eligible routes can compile the validation +
1495
+ // handler continuation once. The runner keeps the bounded parser and all error semantics while
1496
+ // avoiding the generic entry/schema/lifecycle dispatch on Bun, Deno, and Node-direct.
1497
+ const fusedBodyRunner = fusedBody
1498
+ ? this.buildFusedBodyRunner(
1499
+ handler as unknown as InternalHandler,
1500
+ schema.body as StandardSchemaV1,
1501
+ hasDecorations ? routeDecorations : undefined,
1502
+ )
1503
+ : undefined
1126
1504
  const fusedWeb =
1127
1505
  bare && this.aroundHooks.length === 0
1128
1506
  ? this.buildFusedWeb(
@@ -1136,20 +1514,15 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1136
1514
  hasDecorations ? routeDecorations : undefined,
1137
1515
  schema.query as StandardSchemaV1,
1138
1516
  )
1139
- : undefined
1517
+ : fusedBody
1518
+ ? this.buildFusedBodyWeb(fusedBodyRunner as FusedBodyRunner)
1519
+ : undefined
1140
1520
  const contextless = bare && this.aroundHooks.length === 0 && isContextlessNoArgArrow(handler)
1141
1521
  // The body and query lanes finalize the handler's result themselves, so a contracted route takes
1142
1522
  // the general lifecycle lane instead - one place owns the check rather than three.
1143
1523
  const lane = bare
1144
1524
  ? "bare"
1145
- : contracted === undefined &&
1146
- schema?.body !== undefined &&
1147
- schema.query === undefined &&
1148
- schema.params === undefined &&
1149
- this.derives.length === 0 &&
1150
- this.beforeHandleHooks.length === 0 &&
1151
- this.afterHandleHooks.length === 0 &&
1152
- this.onErrorHooks.length === 0
1525
+ : bodyOnly
1153
1526
  ? "body"
1154
1527
  : contracted === undefined &&
1155
1528
  schema?.body === undefined &&
@@ -1161,15 +1534,45 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1161
1534
  this.onErrorHooks.length === 0
1162
1535
  ? "query"
1163
1536
  : "lifecycle"
1537
+ // Lifecycle routes with no params schema are the common middleware shape: a derive/before hook
1538
+ // plus an optional query or body schema. Select their complete validation stage at registration
1539
+ // so the request path never re-checks params/body presence. Parameter-schema routes retain the
1540
+ // generic lifecycle runner until their more involved recovery matrix is selected explicitly.
1541
+ const lifecycleLane =
1542
+ lane !== "lifecycle" || schema?.params !== undefined
1543
+ ? undefined
1544
+ : schema?.body !== undefined
1545
+ ? schema.query !== undefined
1546
+ ? "body-query"
1547
+ : "body"
1548
+ : schema?.query !== undefined
1549
+ ? "query"
1550
+ : "hooks"
1551
+ // The realistic middleware shape is commonly exactly one synchronous-or-async derive followed
1552
+ // by one before hook. Keep the generic runner for every route that can observe decorations,
1553
+ // after hooks, error hooks, or response contracts; this lane only removes the two per-request
1554
+ // hook-loop dispatches and preserves the same async continuations and error handling.
1555
+ const lifecycleHookLane =
1556
+ lane === "lifecycle" &&
1557
+ contracted === undefined &&
1558
+ !hasDecorations &&
1559
+ this.derives.length === 1 &&
1560
+ this.beforeHandleHooks.length === 1 &&
1561
+ this.afterHandleHooks.length === 0 &&
1562
+ this.onErrorHooks.length === 0
1563
+ ? "derive-before"
1564
+ : undefined
1164
1565
  const execution = this.compileExecutionPlan(
1165
1566
  lane,
1166
1567
  contextless,
1167
1568
  this.aroundHooks.length > 0,
1168
1569
  ledgered !== undefined,
1570
+ lifecycleLane,
1169
1571
  fusedWeb,
1170
- fusedWeb === undefined ? undefined : fusedQuery ? "query" : "bare",
1572
+ fusedBodyRunner,
1573
+ fusedWeb === undefined ? undefined : fusedQuery ? "query" : fusedBody ? "body" : "bare",
1171
1574
  )
1172
- const entry: RouteEntry = {
1575
+ const registeredEntry: RouteEntry = {
1173
1576
  // (context: never) => unknown -> InternalHandler: the framework invokes it
1174
1577
  // with the concrete RawContext the typed handler expects, so this is sound.
1175
1578
  handler: handler as unknown as InternalHandler,
@@ -1183,6 +1586,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1183
1586
  beforeHandle: [...this.beforeHandleHooks],
1184
1587
  afterHandle: [...this.afterHandleHooks],
1185
1588
  onError: [...this.onErrorHooks],
1589
+ lifecycleHookLane,
1186
1590
  around: [...this.aroundHooks],
1187
1591
  execution,
1188
1592
  }
@@ -1229,7 +1633,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1229
1633
  method,
1230
1634
  path,
1231
1635
  pattern,
1232
- entry,
1636
+ entry: registeredEntry,
1233
1637
  descriptor,
1234
1638
  assurance: Object.freeze(routeAssurance),
1235
1639
  }
@@ -1242,8 +1646,10 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1242
1646
  contextless: boolean,
1243
1647
  hasAround: boolean,
1244
1648
  hasLedger: boolean,
1649
+ lifecycleLane: "hooks" | "query" | "body" | "body-query" | undefined,
1245
1650
  fusedWeb: FusedWebRunner | undefined,
1246
- fusedLane: "bare" | "query" | undefined,
1651
+ fusedBody: FusedBodyRunner | undefined,
1652
+ fusedLane: "bare" | "body" | "query" | undefined,
1247
1653
  ): RouteExecutionPlan {
1248
1654
  if (contextless) {
1249
1655
  const run: RouteExecutionRunner = (
@@ -1255,6 +1661,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1255
1661
  signal,
1256
1662
  budget,
1257
1663
  platform,
1664
+ _nativeContext,
1258
1665
  finalize,
1259
1666
  wrapResponse,
1260
1667
  ) =>
@@ -1269,7 +1676,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1269
1676
  finalize,
1270
1677
  wrapResponse,
1271
1678
  )
1272
- return Object.freeze({ run, fusedWeb, fusedLane })
1679
+ return Object.freeze({ run, fusedWeb, fusedBody, fusedLane })
1273
1680
  }
1274
1681
 
1275
1682
  let inner: ContextRouteRunner
@@ -1287,8 +1694,27 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1287
1694
  runtime.runQueryOnly(entry, ctx, finalize, wrapResponse)
1288
1695
  break
1289
1696
  default:
1290
- inner = (runtime, entry, source, ctx, finalize, wrapResponse) =>
1291
- runtime.runLifecycle(entry, source, ctx, finalize, wrapResponse)
1697
+ switch (lifecycleLane) {
1698
+ case "hooks":
1699
+ inner = (runtime, entry, _source, ctx, finalize, wrapResponse) =>
1700
+ runtime.runLifecycleHooks(entry, ctx, finalize, wrapResponse)
1701
+ break
1702
+ case "query":
1703
+ inner = (runtime, entry, _source, ctx, finalize, wrapResponse) =>
1704
+ runtime.runLifecycleQuery(entry, ctx, finalize, wrapResponse)
1705
+ break
1706
+ case "body":
1707
+ inner = (runtime, entry, source, ctx, finalize, wrapResponse) =>
1708
+ runtime.runLifecycleBody(entry, source, ctx, finalize, wrapResponse)
1709
+ break
1710
+ case "body-query":
1711
+ inner = (runtime, entry, source, ctx, finalize, wrapResponse) =>
1712
+ runtime.runLifecycleBodyQuery(entry, source, ctx, finalize, wrapResponse)
1713
+ break
1714
+ default:
1715
+ inner = (runtime, entry, source, ctx, finalize, wrapResponse) =>
1716
+ runtime.runLifecycle(entry, source, ctx, finalize, wrapResponse)
1717
+ }
1292
1718
  }
1293
1719
  const execute: ContextRouteRunner = hasAround
1294
1720
  ? (runtime, entry, source, ctx, finalize, wrapResponse) =>
@@ -1309,18 +1735,13 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1309
1735
  signal,
1310
1736
  budget,
1311
1737
  platform,
1738
+ nativeContext,
1312
1739
  finalize,
1313
1740
  wrapResponse,
1314
1741
  ) => {
1315
- const ctx = new RequestContext(
1316
- source,
1317
- params,
1318
- search,
1319
- signal,
1320
- budget,
1321
- platform,
1322
- runtime.maxBodyBytes,
1323
- )
1742
+ const ctx = nativeContext
1743
+ ? RequestContext.native(source, params, search, runtime.maxBodyBytes, platform)
1744
+ : new RequestContext(source, params, search, signal, budget, platform, runtime.maxBodyBytes)
1324
1745
  let ledger: RequestLedger | undefined
1325
1746
  // The runtime is always present when a route resolved a ledger (enforced at registration).
1326
1747
  const ledgerRuntime = runtime.effectLedgerRuntime
@@ -1341,7 +1762,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1341
1762
  }
1342
1763
  return outcome
1343
1764
  }
1344
- return Object.freeze({ run, fusedWeb, fusedLane })
1765
+ return Object.freeze({ run, fusedWeb, fusedBody, fusedLane })
1345
1766
  }
1346
1767
 
1347
1768
  /** The idempotency lane's bridge back into the normal matched lanes, resolved to a concrete Response
@@ -1360,9 +1781,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1360
1781
  entry,
1361
1782
  params,
1362
1783
  search,
1363
- toResponse,
1364
- IDENTITY_RESPONSE,
1365
- RESPONSE_TIMEOUT,
1784
+ this.finalizeResponse,
1785
+ this.wrapWebResponse,
1786
+ this.webResponseTimeout,
1366
1787
  false,
1367
1788
  ),
1368
1789
  )
@@ -1446,8 +1867,23 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1446
1867
  this.sseRuntime ??= source.sseRuntime
1447
1868
  this.wsRuntime ??= source.wsRuntime
1448
1869
  this.onRequestHooks.push(...source.onRequestHooks)
1870
+ this.onNodeRequestHooks.push(...source.onNodeRequestHooks)
1871
+ this.nodeRequestHooksComplete &&= source.nodeRequestHooksComplete
1872
+ // The group's static declarations came before its own response hooks, so they are folded in
1873
+ // first - and fold themselves into a hook here if this server already has one (same ordering
1874
+ // rule as a direct `responseHeaders()` call).
1875
+ if (source.staticResponseHeaders !== undefined) {
1876
+ this.addStaticResponseHeaders({ ...source.staticResponseHeaders.record })
1877
+ }
1449
1878
  this.onResponseHooks.push(...source.onResponseHooks)
1879
+ this.onNodeResponseHooks.push(...source.onNodeResponseHooks)
1880
+ this.nodeResponseHooksComplete &&= source.nodeResponseHooksComplete
1450
1881
  this.onResponseFinalizedHooks.push(...source.onResponseFinalizedHooks)
1882
+ if (source.responseBodyTag !== undefined) {
1883
+ const owner = this.enableResponseBodyTagging()
1884
+ this.responseBodyOwners.add(source.responseBodyTag)
1885
+ source.responseBodyOwners.add(owner)
1886
+ }
1451
1887
  this.globalAssurance.push(...source.globalAssurance)
1452
1888
  this.mcpResourceList.push(...source.mcpResourceList)
1453
1889
  this.mcpPromptList.push(...source.mcpPromptList)
@@ -1461,23 +1897,33 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1461
1897
  if (entry.execution.fusedWeb === undefined) return route
1462
1898
  // Rebuild with the SAME builder that produced the closure - rebinding a query-fused route as
1463
1899
  // bare would silently drop its validation.
1464
- const fusedWeb =
1465
- entry.execution.fusedLane === "query"
1466
- ? this.buildFusedQueryWeb(
1900
+ const fusedBody =
1901
+ entry.execution.fusedLane === "body"
1902
+ ? this.buildFusedBodyRunner(
1467
1903
  entry.handler,
1904
+ entry.schema?.body as StandardSchemaV1,
1468
1905
  entry.hasDecorations ? entry.decorations : undefined,
1469
- entry.schema?.query as StandardSchemaV1,
1470
- )
1471
- : this.buildFusedWeb(
1472
- entry.handler,
1473
- entry.hasDecorations ? entry.decorations : undefined,
1474
- isContextlessNoArgArrow(entry.handler),
1475
1906
  )
1907
+ : undefined
1908
+ const fusedWeb =
1909
+ entry.execution.fusedLane === "body"
1910
+ ? this.buildFusedBodyWeb(fusedBody as FusedBodyRunner)
1911
+ : entry.execution.fusedLane === "query"
1912
+ ? this.buildFusedQueryWeb(
1913
+ entry.handler,
1914
+ entry.hasDecorations ? entry.decorations : undefined,
1915
+ entry.schema?.query as StandardSchemaV1,
1916
+ )
1917
+ : this.buildFusedWeb(
1918
+ entry.handler,
1919
+ entry.hasDecorations ? entry.decorations : undefined,
1920
+ isContextlessNoArgArrow(entry.handler),
1921
+ )
1476
1922
  return {
1477
1923
  ...route,
1478
1924
  entry: {
1479
1925
  ...entry,
1480
- execution: Object.freeze({ ...entry.execution, fusedWeb }),
1926
+ execution: Object.freeze({ ...entry.execution, fusedWeb, fusedBody }),
1481
1927
  },
1482
1928
  }
1483
1929
  }
@@ -1530,9 +1976,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1530
1976
  const outcome = this.dispatch<Response>(
1531
1977
  source,
1532
1978
  platform,
1533
- toResponse,
1534
- IDENTITY_RESPONSE,
1535
- RESPONSE_TIMEOUT,
1979
+ this.finalizeResponse,
1980
+ this.wrapWebResponse,
1981
+ this.webResponseTimeout,
1536
1982
  true,
1537
1983
  )
1538
1984
  if (this.onResponseHooks.length === 0 && this.onResponseFinalizedHooks.length === 0) {
@@ -1569,9 +2015,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1569
2015
  entry,
1570
2016
  params,
1571
2017
  undefined,
1572
- toResponse,
1573
- IDENTITY_RESPONSE,
1574
- RESPONSE_TIMEOUT,
2018
+ this.finalizeResponse,
2019
+ this.wrapWebResponse,
2020
+ this.webResponseTimeout,
1575
2021
  true,
1576
2022
  )
1577
2023
  if (this.onResponseHooks.length === 0 && this.onResponseFinalizedHooks.length === 0) {
@@ -1625,10 +2071,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1625
2071
  * Like {@link fetch}, but renders a plain-data result **without** building a Web `Response` - the
1626
2072
  * `@nifrajs/node` adapter serializes the returned primitives straight to the socket, skipping the undici
1627
2073
  * `Response` build + body drain (the bulk of the Node bridge cost, measured ≈4µs/req). A handler that
1628
- * returns a `Response`, an error/short-circuit, or any registered `onResponse` hook falls back to the
1629
- * full Web path (`{ kind: "response" }`), so behavior is identical - only the common JSON-data case is
1630
- * faster. Same lifecycle as {@link fetch} (body cap, validation, hooks all run); only the final
1631
- * render differs.
2074
+ * returns a `Response`, an error/short-circuit, or a response hook that replaces/consumes the buffered
2075
+ * body stays on the full Web path; an in-place response hook can still use the direct writer. Same
2076
+ * lifecycle as {@link fetch} (body cap, validation, hooks all run); only the final render differs.
1632
2077
  */
1633
2078
  resolveNode(req: Request, platform?: Platform<EnvOf<Ctx>>): MaybePromise<NodeServeOutcome> {
1634
2079
  return this.resolveNodeSource(req, platform)
@@ -1639,13 +2084,21 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1639
2084
  platform?: Platform<EnvOf<Ctx>>,
1640
2085
  suppliedRuntime?: NodeOutcomeRuntime,
1641
2086
  ): MaybePromise<NodeServeOutcome> {
1642
- // onResponse hooks transform a Response, and the capacity gate wraps the Web response path - both
1643
- // force the Web path here (the gated `fetchSource` admits/sheds/releases); wrap its result.
1644
- if (
1645
- this.onResponseHooks.length > 0 ||
1646
- this.onResponseFinalizedHooks.length > 0 ||
1647
- this.capacityGate !== undefined
1648
- ) {
2087
+ // A paired header-only native hook can preserve the Node-direct JSON/body outcome for successful
2088
+ // responses. Arbitrary onResponse transforms need a real Web Response, but a buffered outcome can
2089
+ // be materialized with a direct-write marker and return to the socket path when the hook mutates
2090
+ // it in place. Finalization observers and the capacity gate still wrap the complete Web path.
2091
+ // The native response lane engages only when the REQUEST side is native too (or there are no
2092
+ // request hooks at all). This is what makes the NodeRequestContext identity contract hold: a
2093
+ // web request-hook walk can rewrite the request, so its response-side view is a synthetic
2094
+ // wrapper - a different object - and any middleware carrying per-request state from its request
2095
+ // twin to its response twin through a WeakMap would silently miss. Coupling the gates means a
2096
+ // response twin always sees the exact object its request twin saw.
2097
+ const nativeResponseHooks =
2098
+ this.canUseNodeResponseHooks() &&
2099
+ (this.onRequestHooks.length === 0 || this.canUseNodeRequestHooks())
2100
+ const webResponseHooks = this.onResponseHooks.length > 0 && !nativeResponseHooks
2101
+ if (this.onResponseFinalizedHooks.length > 0 || this.capacityGate !== undefined) {
1649
2102
  const response = this.fetchSource(source, platform)
1650
2103
  return response instanceof Promise
1651
2104
  ? response.then((settled) => ({ kind: "response", response: settled }))
@@ -1661,7 +2114,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1661
2114
  "resolveNode() needs the Node-direct renderer. Normal @nifrajs/node serving installs it automatically; direct callers should add `.use(nodeDirect())` (from `@nifrajs/core/node-direct`).",
1662
2115
  )
1663
2116
  }
1664
- return this.dispatch<NodeServeOutcome>(
2117
+ const resolved = this.dispatch<NodeServeOutcome>(
1665
2118
  source,
1666
2119
  platform,
1667
2120
  runtime.toOutcome,
@@ -1669,6 +2122,175 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1669
2122
  runtime.timeout,
1670
2123
  false,
1671
2124
  )
2125
+ // Fold declared static headers into the record ONCE, here: before any native twin runs (so a
2126
+ // header or body hook reads them through its view), and on the no-hook path too (which returns
2127
+ // the outcome straight to the writer without the finish step below).
2128
+ const statics = this.staticResponseHeaders
2129
+ const outcome =
2130
+ statics === undefined
2131
+ ? resolved
2132
+ : resolved instanceof Promise
2133
+ ? resolved.then((settled) => withStaticNodeHeaders(settled, statics))
2134
+ : withStaticNodeHeaders(resolved, statics)
2135
+ if (webResponseHooks) {
2136
+ return outcome instanceof Promise
2137
+ ? outcome.then((settled) => this.finishNodeWebResponse(settled, source, runtime))
2138
+ : this.finishNodeWebResponse(outcome, source, runtime)
2139
+ }
2140
+ if (!nativeResponseHooks) return outcome
2141
+ try {
2142
+ return outcome instanceof Promise
2143
+ ? outcome.then((settled) => this.finishNodeResponse(settled, source, runtime))
2144
+ : this.finishNodeResponse(outcome, source, runtime)
2145
+ } catch (error) {
2146
+ // Keep resolveNode's failure shape promise-based, matching app.fetch and the adapter bridge.
2147
+ return Promise.reject(error)
2148
+ }
2149
+ }
2150
+
2151
+ /** Run generic Web response middleware while retaining direct writes for untouched buffered bodies. */
2152
+ private finishNodeWebResponse(
2153
+ outcome: NodeServeOutcome,
2154
+ source: RequestSource,
2155
+ runtime: NodeOutcomeRuntime,
2156
+ ): MaybePromise<NodeServeOutcome> {
2157
+ const req = this.takeResponseRequest(source)
2158
+ const response = runtime.toResponse(outcome)
2159
+ const transformed = this.applyOnResponseAndFinalize(response, req)
2160
+ return transformed instanceof Promise
2161
+ ? transformed.then(runtime.fromResponse)
2162
+ : runtime.fromResponse(transformed)
2163
+ }
2164
+
2165
+ /** True only when every transforming Web response hook has a header-only Node equivalent. */
2166
+ private canUseNodeResponseHooks(): boolean {
2167
+ return this.onResponseHooks.length > 0 && this.nodeResponseHooksComplete
2168
+ }
2169
+
2170
+ /** Apply paired native hooks to data outcomes; preserve the complete Web hook pipeline for Response outcomes. */
2171
+ private finishNodeResponse(
2172
+ outcome: NodeServeOutcome,
2173
+ source: RequestSource,
2174
+ runtime: NodeOutcomeRuntime,
2175
+ ): MaybePromise<NodeServeOutcome> {
2176
+ if (outcome.kind === "response") {
2177
+ const req = this.takeResponseRequest(source)
2178
+ const transformed = this.applyOnResponseAndFinalize(outcome.response, req)
2179
+ return transformed instanceof Promise
2180
+ ? transformed.then(runtime.fromResponse)
2181
+ : runtime.fromResponse(transformed)
2182
+ }
2183
+
2184
+ let headers = outcome.headers as Record<string, string | readonly string[]> | undefined
2185
+ if (outcome.kind === "json" && outcome.body !== null) {
2186
+ // The json render adds its Content-Type at WRITE time, so a body hook checking content types
2187
+ // would see nothing. Materialize the writer's own value into the hook-visible record - same
2188
+ // string the writer would emit, so the wire is unchanged.
2189
+ const hasType =
2190
+ headers !== undefined &&
2191
+ Object.keys(headers).some((key) => key.toLowerCase() === "content-type")
2192
+ if (!hasType) {
2193
+ const defaultContentType = runtime.jsonContentType ?? "application/json;charset=utf-8"
2194
+ if (headers === undefined) {
2195
+ headers = { "content-type": defaultContentType }
2196
+ } else {
2197
+ // The record belongs to this outcome. Add the implicit JSON type in place so the native
2198
+ // header walk does not clone every c.set.headers record before it can run. A hook that
2199
+ // replaces or deletes the type still takes the existing withNodeResponseHeaders path;
2200
+ // the writer's final defaulting behavior remains unchanged.
2201
+ headers["content-type"] = defaultContentType
2202
+ }
2203
+ }
2204
+ }
2205
+ const context: NodeResponseContext = {
2206
+ status: outcome.status,
2207
+ headers,
2208
+ cookies: outcome.kind === "json" ? outcome.cookies : undefined,
2209
+ body: outcome.body,
2210
+ }
2211
+ const applied = this.applyNodeResponseHooks(context, this.takeNodeResponseRequest(source))
2212
+ if (applied instanceof Promise) {
2213
+ return applied.then(() => this.withNodeResponseHeaders(outcome, context))
2214
+ }
2215
+ return this.withNodeResponseHeaders(outcome, context)
2216
+ }
2217
+
2218
+ private withNodeResponseHeaders(
2219
+ outcome: Exclude<NodeServeOutcome, { kind: "response" }>,
2220
+ context: NodeResponseContext,
2221
+ ): NodeServeOutcome {
2222
+ const outcomeCookies = outcome.kind === "json" ? outcome.cookies : undefined
2223
+ const bodyChanged = context.body !== outcome.body
2224
+ const statusChanged = context.status !== outcome.status
2225
+ if (
2226
+ context.headers === outcome.headers &&
2227
+ context.cookies === outcomeCookies &&
2228
+ !bodyChanged &&
2229
+ !statusChanged
2230
+ ) {
2231
+ return outcome
2232
+ }
2233
+ let headers = context.headers
2234
+ if (bodyChanged && headers !== undefined) {
2235
+ // A replaced body invalidates any explicitly carried length; the writers re-derive framing
2236
+ // from the final bytes.
2237
+ const stale = Object.keys(headers).find((key) => key.toLowerCase() === "content-length")
2238
+ if (stale !== undefined) {
2239
+ headers = { ...headers }
2240
+ delete headers[stale]
2241
+ }
2242
+ }
2243
+ if (outcome.kind === "json") {
2244
+ if (bodyChanged && context.body !== null && typeof context.body !== "string") {
2245
+ // A binary replacement can't ride the json render - switch to the buffered-body render,
2246
+ // folding queued cookies into explicit set-cookie lines so nothing is dropped.
2247
+ const record = Object.create(null) as Record<string, string | readonly string[]>
2248
+ if (headers !== undefined) Object.assign(record, headers)
2249
+ if (context.cookies !== undefined && context.cookies.length > 0) {
2250
+ record["set-cookie"] = [...context.cookies]
2251
+ }
2252
+ return { kind: "body", status: context.status, headers: record, body: context.body }
2253
+ }
2254
+ return {
2255
+ ...outcome,
2256
+ status: context.status,
2257
+ headers,
2258
+ cookies: context.cookies,
2259
+ body: bodyChanged ? (context.body as string | null) : outcome.body,
2260
+ }
2261
+ }
2262
+ return {
2263
+ ...outcome,
2264
+ status: context.status,
2265
+ headers,
2266
+ body: bodyChanged
2267
+ ? ((context.body ?? new Uint8Array(0)) as string | Uint8Array)
2268
+ : outcome.body,
2269
+ }
2270
+ }
2271
+
2272
+ /** Synchronous until a native response hook actually returns a Promise. */
2273
+ private applyNodeResponseHooks(
2274
+ response: NodeResponseContext,
2275
+ req: NodeRequestContext,
2276
+ ): MaybePromise<void> {
2277
+ for (let i = 0; i < this.onNodeResponseHooks.length; i++) {
2278
+ const hook = this.onNodeResponseHooks[i] as NodeResponseHook
2279
+ const result = hook(response, req)
2280
+ if (result instanceof Promise)
2281
+ return result.then(() => this.continueNodeResponseHooks(i + 1, response, req))
2282
+ }
2283
+ }
2284
+
2285
+ private async continueNodeResponseHooks(
2286
+ start: number,
2287
+ response: NodeResponseContext,
2288
+ req: NodeRequestContext,
2289
+ ): Promise<void> {
2290
+ for (let i = start; i < this.onNodeResponseHooks.length; i++) {
2291
+ const hook = this.onNodeResponseHooks[i] as NodeResponseHook
2292
+ await hook(response, req)
2293
+ }
1672
2294
  }
1673
2295
 
1674
2296
  /**
@@ -1799,9 +2421,69 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1799
2421
  if (this.onRequestHooks.length === 0) {
1800
2422
  return this.routeAndRun(source, resolved, finalize, wrapResponse, onTimeout, webFast)
1801
2423
  }
2424
+ if (!webFast && this.canUseNodeRequestHooks()) {
2425
+ return this.runWithNodeRequest(source, resolved, finalize, wrapResponse, onTimeout)
2426
+ }
1802
2427
  return this.runWithOnRequest(source, resolved, finalize, wrapResponse, onTimeout, webFast)
1803
2428
  }
1804
2429
 
2430
+ /** Node-native request hook walk. A header-only hook can inspect the lazy source without forcing a
2431
+ * Web `Request`; arbitrary request rewrites and full Web hooks use {@link runWithOnRequest}. */
2432
+ private runWithNodeRequest<T>(
2433
+ source: RequestSource,
2434
+ platform: Platform | undefined,
2435
+ finalize: (result: unknown, set: CtxSet) => T,
2436
+ wrapResponse: (response: Response) => T,
2437
+ onTimeout: () => T,
2438
+ ): MaybePromise<T> {
2439
+ const hooks = this.onNodeRequestHooks
2440
+ const request = this.nodeRequestContextOf(source)
2441
+ for (let i = 0; i < hooks.length; i++) {
2442
+ const hook = hooks[i] as NodeRequestHook
2443
+ const outcome = hook(request, platform)
2444
+ if (outcome instanceof Promise) {
2445
+ return outcome.then((early) =>
2446
+ this.continueNodeRequest(
2447
+ early,
2448
+ i + 1,
2449
+ request,
2450
+ source,
2451
+ platform,
2452
+ finalize,
2453
+ wrapResponse,
2454
+ onTimeout,
2455
+ ),
2456
+ )
2457
+ }
2458
+ if (outcome !== undefined) return wrapResponse(outcome)
2459
+ }
2460
+ return this.routeAndRun(source, platform, finalize, wrapResponse, onTimeout, false)
2461
+ }
2462
+
2463
+ private async continueNodeRequest<T>(
2464
+ first: Response | undefined,
2465
+ nextIndex: number,
2466
+ request: NodeRequestContext,
2467
+ source: RequestSource,
2468
+ platform: Platform | undefined,
2469
+ finalize: (result: unknown, set: CtxSet) => T,
2470
+ wrapResponse: (response: Response) => T,
2471
+ onTimeout: () => T,
2472
+ ): Promise<T> {
2473
+ if (first !== undefined) return wrapResponse(first)
2474
+ for (let i = nextIndex; i < this.onNodeRequestHooks.length; i++) {
2475
+ const hook = this.onNodeRequestHooks[i] as NodeRequestHook
2476
+ const outcome = hook(request, platform)
2477
+ const early = outcome instanceof Promise ? await outcome : outcome
2478
+ if (early !== undefined) return wrapResponse(early)
2479
+ }
2480
+ return this.routeAndRun(source, platform, finalize, wrapResponse, onTimeout, false)
2481
+ }
2482
+
2483
+ private canUseNodeRequestHooks(): boolean {
2484
+ return this.onRequestHooks.length > 0 && this.nodeRequestHooksComplete
2485
+ }
2486
+
1805
2487
  /**
1806
2488
  * onRequest short-circuit path. Synchronous as long as every hook returns synchronously (the
1807
2489
  * common case - e.g. CORS returning `undefined` for a non-preflight request): an `async` version
@@ -1819,6 +2501,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1819
2501
  ): MaybePromise<T> {
1820
2502
  const hooks = this.onRequestHooks
1821
2503
  const originalRequest = requestOf(source)
2504
+ this.responseSources.set(source as object, originalRequest)
1822
2505
  let current: RequestSource = source
1823
2506
  for (let i = 0; i < hooks.length; i++) {
1824
2507
  const outcome = (hooks[i] as RawOnRequest)(requestOf(current), platform)
@@ -1879,6 +2562,16 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1879
2562
  }
1880
2563
 
1881
2564
  private takeResponseRequest(source: RequestSource): Request {
2565
+ const tracked = this.responseSources.get(source as object)
2566
+ if (tracked !== undefined) {
2567
+ this.responseSources.delete(source as object)
2568
+ const rewritten = this.responseRequests.get(tracked)
2569
+ if (rewritten !== undefined) {
2570
+ this.responseRequests.delete(tracked)
2571
+ return rewritten
2572
+ }
2573
+ return tracked
2574
+ }
1882
2575
  const request = requestOf(source)
1883
2576
  const rewritten = this.responseRequests.get(request)
1884
2577
  if (rewritten === undefined) return request
@@ -1886,6 +2579,51 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1886
2579
  return rewritten
1887
2580
  }
1888
2581
 
2582
+ /**
2583
+ * The NodeRequestContext for a source, MEMOIZED per source so the request twins and the response
2584
+ * twins receive the exact same object within one request - that identity is the documented
2585
+ * contract stateful twins key their WeakMaps on. An adapter source (which already speaks the
2586
+ * interface) is returned as-is; a plain `Request` source (a direct `resolveNode` caller) gets one
2587
+ * cached wrapper.
2588
+ */
2589
+ private nodeRequestContextOf(source: RequestSource): NodeRequestContext {
2590
+ if (source.header !== undefined) return source as unknown as NodeRequestContext
2591
+ let context = this.nodeContexts.get(source as object)
2592
+ if (context === undefined) {
2593
+ const request = requestOf(source)
2594
+ context = {
2595
+ method: request.method,
2596
+ url: request.url,
2597
+ header: (name) => request.headers.get(name),
2598
+ }
2599
+ this.nodeContexts.set(source as object, context)
2600
+ }
2601
+ return context
2602
+ }
2603
+
2604
+ /** Preserve the request visible to generic onRequest hooks for paired native response hooks. */
2605
+ private takeNodeResponseRequest(source: RequestSource): NodeRequestContext {
2606
+ const tracked = this.responseSources.get(source as object)
2607
+ if (tracked !== undefined) {
2608
+ this.responseSources.delete(source as object)
2609
+ const rewritten = this.responseRequests.get(tracked)
2610
+ if (rewritten !== undefined) {
2611
+ this.responseRequests.delete(tracked)
2612
+ return {
2613
+ method: rewritten.method,
2614
+ url: rewritten.url,
2615
+ header: (name) => rewritten.headers.get(name),
2616
+ }
2617
+ }
2618
+ return {
2619
+ method: tracked.method,
2620
+ url: tracked.url,
2621
+ header: (name) => tracked.headers.get(name),
2622
+ }
2623
+ }
2624
+ return this.nodeRequestContextOf(source)
2625
+ }
2626
+
1889
2627
  /**
1890
2628
  * Route → build context → run. Synchronous through to the handler for a **bare** route
1891
2629
  * (selected by its compiled plan), so a sync handler produces its result with zero promise allocations;
@@ -1899,8 +2637,45 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1899
2637
  onTimeout: () => T,
1900
2638
  webFast: boolean,
1901
2639
  ): MaybePromise<T> {
1902
- const url = source.urlParts ?? urlPartsOf(source.url)
1903
- const match = this.catalog.find(source.method, url.pathname)
2640
+ // Routing only needs the pathname. Scan the URL once but keep the two slices in locals instead
2641
+ // of allocating the `{ pathname, search }` pair returned by the public helper on every request.
2642
+ let pathname: string
2643
+ let search: string
2644
+ // Read the source's split ONCE. `urlParts` is an accessor on the Node request sources that
2645
+ // rescans the target and allocates a fresh pair every time it is touched, so testing it and then
2646
+ // reading each half through the accessor scanned the URL three times per request and threw two
2647
+ // of the three pairs away immediately.
2648
+ const parts = source.urlParts
2649
+ if (parts !== undefined) {
2650
+ pathname = parts.pathname
2651
+ search = parts.search
2652
+ } else {
2653
+ const rawUrl = source.url
2654
+ const schemeEnd = rawUrl.indexOf("://")
2655
+ const start = schemeEnd === -1 ? rawUrl.indexOf("/") : rawUrl.indexOf("/", schemeEnd + 3)
2656
+ if (start === -1) {
2657
+ pathname = "/"
2658
+ search = ""
2659
+ } else {
2660
+ let pathEnd = rawUrl.length
2661
+ let searchStart = -1
2662
+ let searchEnd = rawUrl.length
2663
+ for (let i = start; i < rawUrl.length; i++) {
2664
+ const c = rawUrl.charCodeAt(i)
2665
+ if (c === 63 /* ? */ && searchStart === -1) {
2666
+ pathEnd = i
2667
+ searchStart = i
2668
+ } else if (c === 35 /* # */) {
2669
+ if (searchStart === -1) pathEnd = i
2670
+ searchEnd = i
2671
+ break
2672
+ }
2673
+ }
2674
+ pathname = rawUrl.slice(start, pathEnd)
2675
+ search = searchStart === -1 ? "" : rawUrl.slice(searchStart, searchEnd)
2676
+ }
2677
+ }
2678
+ const match = this.catalog.find(source.method, pathname)
1904
2679
  if (!match.found) {
1905
2680
  if (match.reason === "method-not-allowed") {
1906
2681
  return wrapResponse(
@@ -1922,7 +2697,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1922
2697
  platform,
1923
2698
  match.payload,
1924
2699
  params,
1925
- url.search,
2700
+ search,
1926
2701
  finalize,
1927
2702
  wrapResponse,
1928
2703
  onTimeout,
@@ -2021,6 +2796,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2021
2796
  ? getUnboundedRequestBudget()
2022
2797
  : createRequestBudget({ deadline: admission!.deadline as number, signal })
2023
2798
  const plan = entry.execution
2799
+ const nativeContext = controller === undefined
2024
2800
  const outcome: MaybePromise<T> =
2025
2801
  webFast && plan.fusedWeb !== undefined
2026
2802
  ? (plan.fusedWeb(
@@ -2030,20 +2806,33 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2030
2806
  signal,
2031
2807
  budget,
2032
2808
  platform,
2033
- false,
2809
+ nativeContext,
2034
2810
  ) as MaybePromise<T>)
2035
- : plan.run(
2036
- this,
2037
- entry,
2038
- source,
2039
- params,
2040
- search,
2041
- signal,
2042
- budget,
2043
- platform,
2044
- finalize,
2045
- wrapResponse,
2046
- )
2811
+ : !webFast && plan.fusedBody !== undefined
2812
+ ? plan.fusedBody(
2813
+ source,
2814
+ params,
2815
+ search,
2816
+ signal,
2817
+ budget,
2818
+ platform,
2819
+ false,
2820
+ finalize,
2821
+ wrapResponse,
2822
+ )
2823
+ : plan.run(
2824
+ this,
2825
+ entry,
2826
+ source,
2827
+ params,
2828
+ search,
2829
+ signal,
2830
+ budget,
2831
+ platform,
2832
+ nativeContext,
2833
+ finalize,
2834
+ wrapResponse,
2835
+ )
2047
2836
  // The request timeout only bounds work that is actually pending - a synchronous (bare) result is
2048
2837
  // already complete and can't time out, so it's returned as-is (no 503 race, no promise).
2049
2838
  if (controller !== undefined && outcome instanceof Promise) {
@@ -2183,10 +2972,12 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2183
2972
  decorations: Record<PropertyKey, unknown> | undefined,
2184
2973
  contextless: boolean,
2185
2974
  ): FusedWebRunner {
2975
+ // The fused lanes return to the runtime directly, so the framework's own error renders fold in
2976
+ // static headers here rather than through the shared `wrapResponse` seam.
2186
2977
  const logError = (err: unknown, ctx: RawContext): Response => {
2187
- if (err instanceof Response) return err
2978
+ if (err instanceof Response) return this.wrapWebResponse(err)
2188
2979
  this.logRequestError(err, ctx)
2189
- return jsonError(500, "internal_error")
2980
+ return this.wrapWebResponse(jsonError(500, "internal_error"))
2190
2981
  }
2191
2982
  if (contextless && decorations === undefined) {
2192
2983
  // `() => ...` can't observe the context - skip allocating one entirely (errors still build
@@ -2200,7 +2991,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2200
2991
  return logError(
2201
2992
  err,
2202
2993
  nativeContext
2203
- ? RequestContext.native(source, params, this.maxBodyBytes)
2994
+ ? RequestContext.native(source, params, search, this.maxBodyBytes, platform)
2204
2995
  : new RequestContext(
2205
2996
  source,
2206
2997
  params,
@@ -2213,29 +3004,31 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2213
3004
  )
2214
3005
  }
2215
3006
  if (result instanceof Promise) {
2216
- return result.then(fusedRespondNoSet, (err) =>
2217
- logError(
2218
- err,
2219
- nativeContext
2220
- ? RequestContext.native(source, params, this.maxBodyBytes)
2221
- : new RequestContext(
2222
- source,
2223
- params,
2224
- search,
2225
- signal,
2226
- budget,
2227
- platform,
2228
- this.maxBodyBytes,
2229
- ),
2230
- ),
3007
+ return result.then(
3008
+ (value) => fusedRespondNoSet(value, this.responseBodyTag, this.staticResponseHeaders),
3009
+ (err) =>
3010
+ logError(
3011
+ err,
3012
+ nativeContext
3013
+ ? RequestContext.native(source, params, search, this.maxBodyBytes, platform)
3014
+ : new RequestContext(
3015
+ source,
3016
+ params,
3017
+ search,
3018
+ signal,
3019
+ budget,
3020
+ platform,
3021
+ this.maxBodyBytes,
3022
+ ),
3023
+ ),
2231
3024
  )
2232
3025
  }
2233
- return fusedRespondNoSet(result)
3026
+ return fusedRespondNoSet(result, this.responseBodyTag, this.staticResponseHeaders)
2234
3027
  }
2235
3028
  }
2236
3029
  return (source, params, search, signal, budget, platform, nativeContext) => {
2237
3030
  const ctx = nativeContext
2238
- ? RequestContext.native(source, params, this.maxBodyBytes)
3031
+ ? RequestContext.native(source, params, search, this.maxBodyBytes, platform)
2239
3032
  : new RequestContext(source, params, search, signal, budget, platform, this.maxBodyBytes)
2240
3033
  if (decorations !== undefined) Object.assign(ctx, decorations)
2241
3034
  let result: unknown
@@ -2246,33 +3039,162 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2246
3039
  }
2247
3040
  if (result instanceof Promise) {
2248
3041
  return result.then(
2249
- (value) => fusedRespond(value, ctx),
3042
+ (value) => fusedRespond(value, ctx, this.responseBodyTag, this.staticResponseHeaders),
2250
3043
  (err) => logError(err, ctx),
2251
3044
  )
2252
3045
  }
2253
- return fusedRespond(result, ctx)
3046
+ return fusedRespond(result, ctx, this.responseBodyTag, this.staticResponseHeaders)
2254
3047
  }
2255
3048
  }
2256
3049
 
2257
- /** The fused Web lane for a route whose only lifecycle step is a query schema: parse + validate +
2258
- * handler + respond in one closure, no lifecycle promise when the validator and handler are sync.
2259
- * Eligibility is decided at registration (see `fusedQuery` in {@link register}); the semantics here
2260
- * are exactly `runQueryOnly`'s for that eligible shape - invalid input returns
2261
- * `validationError(issues)` (recovery hooks disqualify the route from this lane), a thrown
2262
- * `Response` passes through, anything else logs and returns a flat 500, and an async validator
2263
- * falls to a then-chain with the same steps. */
2264
- private buildFusedQueryWeb(
3050
+ /** Compile the eligible body-only route's parser → validator → handler continuation once. The
3051
+ * bounded parser remains shared with the generic lane; only the route-invariant entry lookups and
3052
+ * lifecycle dispatch disappear from the common synchronous-validator/synchronous-handler case. */
3053
+ private buildFusedBodyRunner(
2265
3054
  handler: InternalHandler,
3055
+ bodySchema: StandardSchemaV1,
2266
3056
  decorations: Record<PropertyKey, unknown> | undefined,
2267
- querySchema: StandardSchemaV1,
2268
- ): FusedWebRunner {
2269
- const logError = (err: unknown, ctx: RawContext): Response => {
2270
- if (err instanceof Response) return err
2271
- this.logRequestError(err, ctx)
2272
- return jsonError(500, "internal_error")
2273
- }
2274
- const runHandler = (ctx: RawContext, value: unknown): MaybePromise<Response> => {
2275
- ctx.query = value
3057
+ ): FusedBodyRunner {
3058
+ const logError = <T>(
3059
+ err: unknown,
3060
+ ctx: RawContext,
3061
+ wrapResponse: (response: Response) => T,
3062
+ ): T => this.bareError(err, ctx, wrapResponse)
3063
+
3064
+ const runHandler = <T>(
3065
+ ctx: RawContext,
3066
+ finalize: (result: unknown, set: CtxSet, ctx: RawContext) => T,
3067
+ wrapResponse: (response: Response) => T,
3068
+ ): MaybePromise<T> => {
3069
+ if (decorations !== undefined) Object.assign(ctx, decorations)
3070
+ let output: MaybePromise<HandlerResult>
3071
+ try {
3072
+ output = handler(ctx)
3073
+ } catch (err) {
3074
+ return logError(err, ctx, wrapResponse)
3075
+ }
3076
+ if (output instanceof Promise) {
3077
+ return output.then(
3078
+ (result) => {
3079
+ try {
3080
+ return finalize(result, responseSet(ctx), ctx)
3081
+ } catch (err) {
3082
+ return logError(err, ctx, wrapResponse)
3083
+ }
3084
+ },
3085
+ (err) => logError(err, ctx, wrapResponse),
3086
+ )
3087
+ }
3088
+ try {
3089
+ return finalize(output, responseSet(ctx), ctx)
3090
+ } catch (err) {
3091
+ return logError(err, ctx, wrapResponse)
3092
+ }
3093
+ }
3094
+
3095
+ const runValidated = <T>(
3096
+ result: StandardResult<unknown>,
3097
+ ctx: RawContext,
3098
+ finalize: (result: unknown, set: CtxSet, ctx: RawContext) => T,
3099
+ wrapResponse: (response: Response) => T,
3100
+ ): MaybePromise<T> => {
3101
+ if (result.issues !== undefined) return wrapResponse(validationError(result.issues))
3102
+ ctx.body = result.value
3103
+ return runHandler(ctx, finalize, wrapResponse)
3104
+ }
3105
+
3106
+ const runParsed = <T>(
3107
+ parsed: unknown,
3108
+ ctx: RawContext,
3109
+ finalize: (result: unknown, set: CtxSet, ctx: RawContext) => T,
3110
+ wrapResponse: (response: Response) => T,
3111
+ ): MaybePromise<T> => {
3112
+ let validation: StandardResult<unknown> | Promise<StandardResult<unknown>>
3113
+ try {
3114
+ validation = bodySchema["~standard"].validate(parsed)
3115
+ } catch (err) {
3116
+ return logError(err, ctx, wrapResponse)
3117
+ }
3118
+ if (validation instanceof Promise) {
3119
+ return validation.then(
3120
+ (settled) => {
3121
+ try {
3122
+ return runValidated(settled, ctx, finalize, wrapResponse)
3123
+ } catch (err) {
3124
+ return logError(err, ctx, wrapResponse)
3125
+ }
3126
+ },
3127
+ (err) => logError(err, ctx, wrapResponse),
3128
+ )
3129
+ }
3130
+ try {
3131
+ return runValidated(validation, ctx, finalize, wrapResponse)
3132
+ } catch (err) {
3133
+ return logError(err, ctx, wrapResponse)
3134
+ }
3135
+ }
3136
+
3137
+ return <T>(
3138
+ source: RequestSource,
3139
+ params: Record<string, string>,
3140
+ search: string | undefined,
3141
+ signal: AbortSignal,
3142
+ budget: RequestBudget,
3143
+ platform: Platform | undefined,
3144
+ nativeContext: boolean,
3145
+ finalize: (result: unknown, set: CtxSet, ctx: RawContext) => T,
3146
+ wrapResponse: (response: Response) => T,
3147
+ ): MaybePromise<T> => {
3148
+ const ctx = nativeContext
3149
+ ? RequestContext.native(source, params, search, this.maxBodyBytes, platform)
3150
+ : new RequestContext(source, params, search, signal, budget, platform, this.maxBodyBytes)
3151
+ const finish = (value: unknown): MaybePromise<T> =>
3152
+ runParsed(value, ctx, finalize, wrapResponse)
3153
+ return this.readBodyInput(source, finish, wrapResponse, (err) =>
3154
+ logError(err, ctx, wrapResponse),
3155
+ )
3156
+ }
3157
+ }
3158
+
3159
+ /** Web adapter wrapper for the shared body runner. Node passes its native finalizer directly through
3160
+ * the execution plan, while Web needs the live context to preserve lazy `c.set` controls. */
3161
+ private buildFusedBodyWeb(body: FusedBodyRunner): FusedWebRunner {
3162
+ return (source, params, search, signal, budget, platform, nativeContext) =>
3163
+ body(
3164
+ source,
3165
+ params,
3166
+ search,
3167
+ signal,
3168
+ budget,
3169
+ platform,
3170
+ nativeContext,
3171
+ (result, _set, ctx) =>
3172
+ fusedRespond(result, ctx, this.responseBodyTag, this.staticResponseHeaders),
3173
+ this.wrapWebResponse,
3174
+ ) as MaybePromise<Response>
3175
+ }
3176
+
3177
+ /** The fused Web lane for a route whose only lifecycle step is a query schema: parse + validate +
3178
+ * handler + respond in one closure, no lifecycle promise when the validator and handler are sync.
3179
+ * Eligibility is decided at registration (see `fusedQuery` in {@link register}); the semantics here
3180
+ * are exactly `runQueryOnly`'s for that eligible shape - invalid input returns
3181
+ * `validationError(issues)` (recovery hooks disqualify the route from this lane), a thrown
3182
+ * `Response` passes through, anything else logs and returns a flat 500, and an async validator
3183
+ * falls to a then-chain with the same steps. */
3184
+ private buildFusedQueryWeb(
3185
+ handler: InternalHandler,
3186
+ decorations: Record<PropertyKey, unknown> | undefined,
3187
+ querySchema: StandardSchemaV1,
3188
+ ): FusedWebRunner {
3189
+ // The fused lanes return to the runtime directly, so the framework's own error renders fold in
3190
+ // static headers here rather than through the shared `wrapResponse` seam.
3191
+ const logError = (err: unknown, ctx: RawContext): Response => {
3192
+ if (err instanceof Response) return this.wrapWebResponse(err)
3193
+ this.logRequestError(err, ctx)
3194
+ return this.wrapWebResponse(jsonError(500, "internal_error"))
3195
+ }
3196
+ const runHandler = (ctx: RawContext, value: unknown): MaybePromise<Response> => {
3197
+ ctx.query = value
2276
3198
  let result: unknown
2277
3199
  try {
2278
3200
  result = handler(ctx)
@@ -2281,15 +3203,15 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2281
3203
  }
2282
3204
  if (result instanceof Promise) {
2283
3205
  return result.then(
2284
- (settled) => fusedRespond(settled, ctx),
3206
+ (settled) => fusedRespond(settled, ctx, this.responseBodyTag, this.staticResponseHeaders),
2285
3207
  (err) => logError(err, ctx),
2286
3208
  )
2287
3209
  }
2288
- return fusedRespond(result, ctx)
3210
+ return fusedRespond(result, ctx, this.responseBodyTag, this.staticResponseHeaders)
2289
3211
  }
2290
3212
  return (source, params, search, signal, budget, platform, nativeContext) => {
2291
3213
  const ctx = nativeContext
2292
- ? RequestContext.native(source, params, this.maxBodyBytes)
3214
+ ? RequestContext.native(source, params, search, this.maxBodyBytes, platform)
2293
3215
  : new RequestContext(source, params, search, signal, budget, platform, this.maxBodyBytes)
2294
3216
  if (decorations !== undefined) Object.assign(ctx, decorations)
2295
3217
  let validation: MaybePromise<StandardResult<unknown>>
@@ -2302,12 +3224,13 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2302
3224
  return validation.then(
2303
3225
  (settled) =>
2304
3226
  settled.issues !== undefined
2305
- ? validationError(settled.issues)
3227
+ ? this.wrapWebResponse(validationError(settled.issues))
2306
3228
  : runHandler(ctx, settled.value),
2307
3229
  (err) => logError(err, ctx),
2308
3230
  )
2309
3231
  }
2310
- if (validation.issues !== undefined) return validationError(validation.issues)
3232
+ if (validation.issues !== undefined)
3233
+ return this.wrapWebResponse(validationError(validation.issues))
2311
3234
  return runHandler(ctx, validation.value)
2312
3235
  }
2313
3236
  }
@@ -2360,26 +3283,36 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2360
3283
  ctx: RawContext,
2361
3284
  finalize: (result: unknown, set: CtxSet) => T,
2362
3285
  wrapResponse: (response: Response) => T,
3286
+ ): Promise<T> {
3287
+ return this.readBodyInput(
3288
+ source,
3289
+ (parsed) => this.finishBodyOnly(entry, parsed, ctx, finalize, wrapResponse),
3290
+ wrapResponse,
3291
+ (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse),
3292
+ )
3293
+ }
3294
+
3295
+ /** Shared bounded body framing/parser. Both body lanes use the same trust-boundary checks; only
3296
+ * the validation + handler continuation differs. */
3297
+ private readBodyInput<T>(
3298
+ source: RequestSource,
3299
+ onParsed: (parsed: unknown) => MaybePromise<T>,
3300
+ wrapResponse: (response: Response) => T,
3301
+ onError: (err: unknown) => MaybePromise<T>,
2363
3302
  ): Promise<T> {
2364
3303
  const contentType = headerOf(source, "content-type") ?? ""
2365
3304
  if (contentType !== "application/json" && !contentType.includes("application/json")) {
2366
3305
  if (isUrlEncodedForm(contentType)) {
2367
- // HTML form posts validate through the same schema as JSON (parity with Elysia/Hono).
2368
- // Off the JSON inline lane on purpose: forms are a fraction of API traffic, and the
2369
- // bounded form reader shares the streaming guard. JSON requests are untouched.
2370
- return readBoundedForm(source, this.maxBodyBytes).then((form) => {
2371
- if (form instanceof Response) return wrapResponse(form)
2372
- return this.finishBodyOnly(entry, form, ctx, finalize, wrapResponse) as Promise<T> | T
2373
- }) as Promise<T>
3306
+ return readBoundedForm(source, this.maxBodyBytes).then(
3307
+ (form) => (form instanceof Response ? wrapResponse(form) : onParsed(form)),
3308
+ onError,
3309
+ ) as Promise<T>
2374
3310
  }
2375
3311
  return Promise.resolve(wrapResponse(jsonError(415, "unsupported_media_type")))
2376
3312
  }
2377
3313
 
2378
- // Inline fast path (profiled): a framed, in-cap, non-chunked body parses with the native
2379
- // `req.json()` directly - one promise, one `.then` closure per request. The generic
2380
- // `readBoundedJson` (an extra async-fn frame + per-request `finish`/`applyValidation`
2381
- // closures) is kept for the chunked / length-less / oversized cases it exists for.
2382
- // Semantics are identical to readBoundedJsonSource - same checks, same error codes.
3314
+ // A framed, in-cap, non-chunked body can use native json() directly. Chunked or length-less
3315
+ // bodies use readBoundedJson, which enforces the streaming cap before parsing.
2383
3316
  const declared = headerOf(source, "content-length")
2384
3317
  if (declared !== null) {
2385
3318
  const length = parseContentLength(declared)
@@ -2390,25 +3323,17 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2390
3323
  return Promise.resolve(wrapResponse(jsonError(413, "payload_too_large")))
2391
3324
  }
2392
3325
  if (headerOf(source, "transfer-encoding") === null) {
2393
- return source.json().then(
2394
- (parsed) => this.finishBodyOnly(entry, parsed, ctx, finalize, wrapResponse),
2395
- // Native json() rejection = malformed JSON; anything past parse goes through
2396
- // finishBodyOnly's own error handling.
2397
- () => wrapResponse(jsonError(400, "invalid_json")),
2398
- )
3326
+ return source.json().then(onParsed, () => wrapResponse(jsonError(400, "invalid_json")))
2399
3327
  }
2400
3328
  }
2401
3329
 
2402
3330
  try {
2403
3331
  return this.readBoundedJson(source).then(
2404
- (parsed) => {
2405
- if (parsed instanceof Response) return wrapResponse(parsed)
2406
- return this.finishBodyOnly(entry, parsed, ctx, finalize, wrapResponse)
2407
- },
2408
- (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse),
3332
+ (parsed) => (parsed instanceof Response ? wrapResponse(parsed) : onParsed(parsed)),
3333
+ onError,
2409
3334
  )
2410
3335
  } catch (err) {
2411
- return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse)
3336
+ return Promise.resolve(onError(err))
2412
3337
  }
2413
3338
  }
2414
3339
 
@@ -2682,14 +3607,18 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2682
3607
  response: Response,
2683
3608
  error: unknown,
2684
3609
  req: Request,
2685
- ): Promise<never> | never {
3610
+ ): Promise<never> {
2686
3611
  const notified = this.notifyResponseFinalized({ response, error }, req)
2687
3612
  if (notified instanceof Promise) {
2688
3613
  return notified.then(() => {
2689
3614
  throw error
2690
3615
  })
2691
3616
  }
2692
- throw error
3617
+ // A hook failure must surface as a REJECTION even when every prior step ran synchronously:
3618
+ // `fetch()` may now resolve without a promise, but its failure contract stays promise-shaped -
3619
+ // a synchronous throw here would escape `Promise.resolve(app.fetch(...))` bridges and
3620
+ // `.then()`-style callers entirely instead of reaching their rejection handling.
3621
+ return Promise.reject(error)
2693
3622
  }
2694
3623
 
2695
3624
  /** Notify terminal observers in order while isolating both sync and async failures. */
@@ -2760,109 +3689,532 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2760
3689
  * Generic over the render: a success value goes through `finalize(result, set)` (a Web `Response`, or
2761
3690
  * node-direct primitives); an early/error `Response` (validation, thrown, 500) through `wrapResponse`.
2762
3691
  */
2763
- private async runLifecycle<T>(
3692
+ /** Synchronous until a validator, lifecycle hook, handler, or contract check actually returns a Promise. */
3693
+ private runLifecycle<T>(
2764
3694
  entry: RouteEntry,
2765
3695
  source: RequestSource,
2766
3696
  ctx: RawContext,
2767
3697
  finalize: (result: unknown, set: CtxSet) => T,
2768
3698
  wrapResponse: (response: Response) => T,
2769
- ): Promise<T> {
3699
+ ): MaybePromise<T> {
2770
3700
  try {
2771
- if (entry.schema?.params !== undefined) {
2772
- // Path params arrive as strings from routing; validate (and coerce) them at the boundary, before
2773
- // the body/query, so a malformed `:id` is a 422 before any work runs. Raw `.validate` (no wrapper
2774
- // alloc), same recovery-hook path as body/query.
2775
- const validation = entry.schema.params["~standard"].validate(ctx.params)
2776
- const result = validation instanceof Promise ? await validation : validation
2777
- const paramsError = await this.applyLifecycleValidation(entry, result, ctx, "params")
2778
- if (paramsError !== undefined) return wrapResponse(paramsError)
2779
- }
2780
- if (entry.schema?.body !== undefined) {
2781
- const bodyError = await this.readAndValidateBody(source, entry, ctx)
2782
- if (bodyError !== undefined) return wrapResponse(bodyError)
2783
- }
2784
- if (entry.schema?.query !== undefined) {
2785
- // Repeated query keys promote to `string[]` (so an array schema validates `?a=1&a=2`); a
2786
- // single occurrence stays a string. Raw `.validate` (read `.issues`/`.value`) skips the
2787
- // validateStandard wrapper alloc.
2788
- const validation = entry.schema.query["~standard"].validate(
2789
- queryObjectOf(ctx[CONTEXT_SEARCH]),
2790
- )
2791
- const result = validation instanceof Promise ? await validation : validation
2792
- const queryError = await this.applyLifecycleValidation(entry, result, ctx, "query")
2793
- if (queryError !== undefined) return wrapResponse(queryError)
2794
- }
2795
-
2796
- // Context extensions: static decorations, then per-request derives.
2797
- // Each hook below only awaits when it actually returns a Promise - a sync hook
2798
- // skips the microtask tick (a fast-path; ~64 ns/hook).
2799
- if (entry.hasDecorations) Object.assign(ctx, entry.decorations) // skip the no-op on bare routes
2800
- // The `.length` guards skip iterator setup for the common no-hook route (most
2801
- // routes have neither derives nor before/after hooks); a hooked route pays only
2802
- // the length check.
2803
- if (entry.derives.length > 0) {
2804
- for (const derive of entry.derives) {
2805
- const extension = derive(ctx)
2806
- Object.assign(ctx, extension instanceof Promise ? await extension : extension)
3701
+ const paramsSchema = entry.schema?.params
3702
+ if (paramsSchema !== undefined) {
3703
+ const validation = paramsSchema["~standard"].validate(ctx.params)
3704
+ if (validation instanceof Promise) {
3705
+ return validation.then(
3706
+ (result) =>
3707
+ this.runLifecycleAfterParamsResult(
3708
+ entry,
3709
+ source,
3710
+ ctx,
3711
+ finalize,
3712
+ wrapResponse,
3713
+ result,
3714
+ ),
3715
+ (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse),
3716
+ )
2807
3717
  }
3718
+ return this.runLifecycleAfterParamsResult(
3719
+ entry,
3720
+ source,
3721
+ ctx,
3722
+ finalize,
3723
+ wrapResponse,
3724
+ validation,
3725
+ )
3726
+ }
3727
+ // Registration already knows whether this route has body/params/query validation. Most real
3728
+ // read routes have no body or params schema, so skip the generic stage ladder and go straight to
3729
+ // query validation + lifecycle hooks. The fallback below remains the complete path for routes
3730
+ // with body/params combinations and validation recovery.
3731
+ if (entry.schema?.body === undefined) {
3732
+ return this.runQueryAndLifecycle(entry, ctx, finalize, wrapResponse)
2808
3733
  }
3734
+ return this.runLifecycleAfterParams(entry, source, ctx, finalize, wrapResponse, undefined)
3735
+ } catch (err) {
3736
+ return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse)
3737
+ }
3738
+ }
2809
3739
 
2810
- // beforeHandle: a non-undefined return short-circuits, skipping the handler.
2811
- if (entry.beforeHandle.length > 0) {
2812
- for (const hook of entry.beforeHandle) {
2813
- const outcome = hook(ctx)
2814
- const early = outcome instanceof Promise ? await outcome : outcome
2815
- if (early !== undefined) return finalize(early, responseSet(ctx))
2816
- }
3740
+ /** Registration-specialized query lifecycle: query validation (including recovery) → hooks. */
3741
+ private runLifecycleQuery<T>(
3742
+ entry: RouteEntry,
3743
+ ctx: RawContext,
3744
+ finalize: (result: unknown, set: CtxSet) => T,
3745
+ wrapResponse: (response: Response) => T,
3746
+ ): MaybePromise<T> {
3747
+ try {
3748
+ // The execution plan only selects this runner when the route has a query schema. Keeping the
3749
+ // non-null access here removes the per-request schema-presence branch from realistic GETs.
3750
+ const validation = entry.schema!.query!["~standard"].validate(
3751
+ queryObjectOf(ctx[CONTEXT_SEARCH]),
3752
+ )
3753
+ if (validation instanceof Promise) {
3754
+ return validation.then(
3755
+ (result) => {
3756
+ try {
3757
+ return this.runQueryAndLifecycleResult(entry, ctx, finalize, wrapResponse, result)
3758
+ } catch (err) {
3759
+ return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse)
3760
+ }
3761
+ },
3762
+ (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse),
3763
+ )
2817
3764
  }
3765
+ return this.runQueryAndLifecycleResult(entry, ctx, finalize, wrapResponse, validation)
3766
+ } catch (err) {
3767
+ return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse)
3768
+ }
3769
+ }
3770
+
3771
+ /** Registration-specialized body lifecycle: bounded body validation (including recovery) → hooks. */
3772
+ private runLifecycleBody<T>(
3773
+ entry: RouteEntry,
3774
+ source: RequestSource,
3775
+ ctx: RawContext,
3776
+ finalize: (result: unknown, set: CtxSet) => T,
3777
+ wrapResponse: (response: Response) => T,
3778
+ ): MaybePromise<T> {
3779
+ return this.readAndValidateBody(source, entry, ctx).then(
3780
+ (bodyError) =>
3781
+ bodyError === undefined
3782
+ ? this.runLifecycleHooks(entry, ctx, finalize, wrapResponse)
3783
+ : wrapResponse(bodyError),
3784
+ (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse),
3785
+ )
3786
+ }
2818
3787
 
2819
- const handlerOutput = entry.handler(ctx)
2820
- let result = handlerOutput instanceof Promise ? await handlerOutput : handlerOutput
3788
+ /** Registration-specialized body + query lifecycle: bounded body validation → query validation → hooks. */
3789
+ private runLifecycleBodyQuery<T>(
3790
+ entry: RouteEntry,
3791
+ source: RequestSource,
3792
+ ctx: RawContext,
3793
+ finalize: (result: unknown, set: CtxSet) => T,
3794
+ wrapResponse: (response: Response) => T,
3795
+ ): MaybePromise<T> {
3796
+ return this.readAndValidateBody(source, entry, ctx).then(
3797
+ (bodyError) =>
3798
+ bodyError === undefined
3799
+ ? this.runLifecycleQuery(entry, ctx, finalize, wrapResponse)
3800
+ : wrapResponse(bodyError),
3801
+ (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse),
3802
+ )
3803
+ }
2821
3804
 
2822
- // afterHandle: transform the result before serialization.
2823
- if (entry.afterHandle.length > 0) {
2824
- for (const hook of entry.afterHandle) {
2825
- const transformed = hook(result, ctx)
2826
- result = transformed instanceof Promise ? await transformed : transformed
2827
- }
3805
+ /** Registration-specialized no-body lifecycle: query validation (if present) → derives/hooks. */
3806
+ private runQueryAndLifecycle<T>(
3807
+ entry: RouteEntry,
3808
+ ctx: RawContext,
3809
+ finalize: (result: unknown, set: CtxSet) => T,
3810
+ wrapResponse: (response: Response) => T,
3811
+ ): MaybePromise<T> {
3812
+ const querySchema = entry.schema?.query
3813
+ return querySchema === undefined
3814
+ ? this.runLifecycleHooks(entry, ctx, finalize, wrapResponse)
3815
+ : this.runLifecycleQuery(entry, ctx, finalize, wrapResponse)
3816
+ }
3817
+
3818
+ private runQueryAndLifecycleResult<T>(
3819
+ entry: RouteEntry,
3820
+ ctx: RawContext,
3821
+ finalize: (result: unknown, set: CtxSet) => T,
3822
+ wrapResponse: (response: Response) => T,
3823
+ result: StandardResult<unknown>,
3824
+ ): MaybePromise<T> {
3825
+ const queryError = this.applyLifecycleValidation(entry, result, ctx, "query")
3826
+ if (queryError instanceof Promise) {
3827
+ return queryError.then(
3828
+ (error) =>
3829
+ error === undefined
3830
+ ? this.runLifecycleHooks(entry, ctx, finalize, wrapResponse)
3831
+ : wrapResponse(error),
3832
+ (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse),
3833
+ )
3834
+ }
3835
+ if (queryError !== undefined) return wrapResponse(queryError)
3836
+ return this.runLifecycleHooks(entry, ctx, finalize, wrapResponse)
3837
+ }
3838
+
3839
+ private runLifecycleAfterParamsResult<T>(
3840
+ entry: RouteEntry,
3841
+ source: RequestSource,
3842
+ ctx: RawContext,
3843
+ finalize: (result: unknown, set: CtxSet) => T,
3844
+ wrapResponse: (response: Response) => T,
3845
+ result: StandardResult<unknown>,
3846
+ ): MaybePromise<T> {
3847
+ try {
3848
+ return this.runLifecycleAfterParams(
3849
+ entry,
3850
+ source,
3851
+ ctx,
3852
+ finalize,
3853
+ wrapResponse,
3854
+ this.applyLifecycleValidation(entry, result, ctx, "params"),
3855
+ )
3856
+ } catch (err) {
3857
+ return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse)
3858
+ }
3859
+ }
3860
+
3861
+ private runLifecycleAfterParams<T>(
3862
+ entry: RouteEntry,
3863
+ source: RequestSource,
3864
+ ctx: RawContext,
3865
+ finalize: (result: unknown, set: CtxSet) => T,
3866
+ wrapResponse: (response: Response) => T,
3867
+ paramsError: MaybePromise<Response | undefined>,
3868
+ ): MaybePromise<T> {
3869
+ if (paramsError instanceof Promise) {
3870
+ return paramsError.then(
3871
+ (error) =>
3872
+ this.runLifecycleAfterParamsSettled(entry, source, ctx, finalize, wrapResponse, error),
3873
+ (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse),
3874
+ )
3875
+ }
3876
+ return this.runLifecycleAfterParamsSettled(
3877
+ entry,
3878
+ source,
3879
+ ctx,
3880
+ finalize,
3881
+ wrapResponse,
3882
+ paramsError,
3883
+ )
3884
+ }
3885
+
3886
+ private runLifecycleAfterParamsSettled<T>(
3887
+ entry: RouteEntry,
3888
+ source: RequestSource,
3889
+ ctx: RawContext,
3890
+ finalize: (result: unknown, set: CtxSet) => T,
3891
+ wrapResponse: (response: Response) => T,
3892
+ paramsError: Response | undefined,
3893
+ ): MaybePromise<T> {
3894
+ if (paramsError !== undefined) return wrapResponse(paramsError)
3895
+ if (entry.schema?.body !== undefined) {
3896
+ return this.readAndValidateBody(source, entry, ctx).then(
3897
+ (bodyError) => this.runLifecycleAfterBody(entry, ctx, finalize, wrapResponse, bodyError),
3898
+ (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse),
3899
+ )
3900
+ }
3901
+ return this.runLifecycleAfterBody(entry, ctx, finalize, wrapResponse, undefined)
3902
+ }
3903
+
3904
+ private runLifecycleAfterBody<T>(
3905
+ entry: RouteEntry,
3906
+ ctx: RawContext,
3907
+ finalize: (result: unknown, set: CtxSet) => T,
3908
+ wrapResponse: (response: Response) => T,
3909
+ bodyError: Response | undefined,
3910
+ ): MaybePromise<T> {
3911
+ if (bodyError !== undefined) return wrapResponse(bodyError)
3912
+ const querySchema = entry.schema?.query
3913
+ if (querySchema === undefined) return this.runLifecycleHooks(entry, ctx, finalize, wrapResponse)
3914
+ const validation = querySchema["~standard"].validate(queryObjectOf(ctx[CONTEXT_SEARCH]))
3915
+ if (validation instanceof Promise) {
3916
+ return validation.then(
3917
+ (result) => this.runLifecycleAfterQueryResult(entry, ctx, finalize, wrapResponse, result),
3918
+ (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse),
3919
+ )
3920
+ }
3921
+ return this.runLifecycleAfterQueryResult(entry, ctx, finalize, wrapResponse, validation)
3922
+ }
3923
+
3924
+ private runLifecycleAfterQueryResult<T>(
3925
+ entry: RouteEntry,
3926
+ ctx: RawContext,
3927
+ finalize: (result: unknown, set: CtxSet) => T,
3928
+ wrapResponse: (response: Response) => T,
3929
+ result: StandardResult<unknown>,
3930
+ ): MaybePromise<T> {
3931
+ try {
3932
+ const queryError = this.applyLifecycleValidation(entry, result, ctx, "query")
3933
+ if (queryError instanceof Promise) {
3934
+ return queryError.then(
3935
+ (error) => this.runLifecycleAfterQuery(entry, ctx, finalize, wrapResponse, error),
3936
+ (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse),
3937
+ )
2828
3938
  }
3939
+ return this.runLifecycleAfterQuery(entry, ctx, finalize, wrapResponse, queryError)
3940
+ } catch (err) {
3941
+ return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse)
3942
+ }
3943
+ }
2829
3944
 
2830
- // The response contract is checked LAST, after afterHandle, so it holds what actually ships
2831
- // rather than what the handler happened to return. Only reached by a contracted route: the
2832
- // registration decision above kept those off the fused/native lanes for exactly this step.
2833
- const contract = entry.responseContract
2834
- if (contract !== undefined) {
2835
- const checked = contract.runtime.check(contract.schema, result)
2836
- const outcome = checked instanceof Promise ? await checked : checked
2837
- if (outcome.kind === "violation") {
2838
- // The handler broke a contract it declared itself, so this is a server fault, not the
2839
- // caller's. Fail closed: the undeclared payload is never sent, and the detail goes to the
2840
- // (redacting) logger rather than to the client.
2841
- this.logger.error("response contract violation", {
2842
- method: ctx.req.method,
2843
- path: pathnameOf(ctx.req.url),
2844
- // `detail`, not `message`: the logger uses `message` for its own first argument, so a
2845
- // field of that name is silently overwritten and the diagnosis disappears.
2846
- detail: outcome.message,
2847
- })
2848
- return wrapResponse(jsonError(500, "internal_error"))
3945
+ private runLifecycleAfterQuery<T>(
3946
+ entry: RouteEntry,
3947
+ ctx: RawContext,
3948
+ finalize: (result: unknown, set: CtxSet) => T,
3949
+ wrapResponse: (response: Response) => T,
3950
+ queryError: Response | undefined,
3951
+ ): MaybePromise<T> {
3952
+ if (queryError !== undefined) return wrapResponse(queryError)
3953
+ return this.runLifecycleHooks(entry, ctx, finalize, wrapResponse)
3954
+ }
3955
+
3956
+ private runLifecycleHooks<T>(
3957
+ entry: RouteEntry,
3958
+ ctx: RawContext,
3959
+ finalize: (result: unknown, set: CtxSet) => T,
3960
+ wrapResponse: (response: Response) => T,
3961
+ ): MaybePromise<T> {
3962
+ if (entry.lifecycleHookLane === "derive-before") {
3963
+ return this.runLifecycleDeriveBefore(entry, ctx, finalize, wrapResponse)
3964
+ }
3965
+ try {
3966
+ if (entry.hasDecorations) Object.assign(ctx, entry.decorations)
3967
+ for (let i = 0; i < entry.derives.length; i++) {
3968
+ const extension = entry.derives[i]!(ctx)
3969
+ if (extension instanceof Promise) {
3970
+ return this.continueLifecycleAfterDerive(entry, ctx, finalize, wrapResponse, i, extension)
2849
3971
  }
2850
- if (outcome.kind === "warn") {
2851
- this.logger.warn("response contract", {
2852
- method: ctx.req.method,
2853
- path: pathnameOf(ctx.req.url),
2854
- detail: outcome.message,
2855
- })
3972
+ Object.assign(ctx, extension)
3973
+ }
3974
+ for (let i = 0; i < entry.beforeHandle.length; i++) {
3975
+ const outcome = entry.beforeHandle[i]!(ctx)
3976
+ if (outcome instanceof Promise) {
3977
+ return this.continueLifecycleAfterBefore(entry, ctx, finalize, wrapResponse, i, outcome)
2856
3978
  }
2857
- result = outcome.value
3979
+ if (outcome !== undefined) return finalize(outcome, responseSet(ctx))
3980
+ }
3981
+ const result = entry.handler(ctx)
3982
+ if (result instanceof Promise) {
3983
+ return result.then(
3984
+ (value) => this.finishLifecycleResult(entry, ctx, finalize, wrapResponse, value),
3985
+ (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse),
3986
+ )
2858
3987
  }
3988
+ return this.finishLifecycleResult(entry, ctx, finalize, wrapResponse, result)
3989
+ } catch (err) {
3990
+ return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse)
3991
+ }
3992
+ }
3993
+
3994
+ /** Registration-specialized derive → before → handler lifecycle. */
3995
+ private runLifecycleDeriveBefore<T>(
3996
+ entry: RouteEntry,
3997
+ ctx: RawContext,
3998
+ finalize: (result: unknown, set: CtxSet) => T,
3999
+ wrapResponse: (response: Response) => T,
4000
+ ): MaybePromise<T> {
4001
+ try {
4002
+ const extension = entry.derives[0]!(ctx)
4003
+ if (extension instanceof Promise) {
4004
+ return this.continueLifecycleAfterDerive(entry, ctx, finalize, wrapResponse, 0, extension)
4005
+ }
4006
+ Object.assign(ctx, extension)
4007
+
4008
+ const outcome = entry.beforeHandle[0]!(ctx)
4009
+ if (outcome instanceof Promise) {
4010
+ return this.continueLifecycleAfterBefore(entry, ctx, finalize, wrapResponse, 0, outcome)
4011
+ }
4012
+ if (outcome !== undefined)
4013
+ return this.finishSimpleLifecycleResult(entry, ctx, finalize, wrapResponse, outcome)
4014
+
4015
+ const result = entry.handler(ctx)
4016
+ if (result instanceof Promise) {
4017
+ return result.then(
4018
+ (value) => this.finishSimpleLifecycleResult(entry, ctx, finalize, wrapResponse, value),
4019
+ (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse),
4020
+ )
4021
+ }
4022
+ return this.finishSimpleLifecycleResult(entry, ctx, finalize, wrapResponse, result)
4023
+ } catch (err) {
4024
+ return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse)
4025
+ }
4026
+ }
2859
4027
 
4028
+ /** Finalize the specialized lane without rechecking its registration-proven invariants. */
4029
+ private finishSimpleLifecycleResult<T>(
4030
+ entry: RouteEntry,
4031
+ ctx: RawContext,
4032
+ finalize: (result: unknown, set: CtxSet) => T,
4033
+ wrapResponse: (response: Response) => T,
4034
+ result: unknown,
4035
+ ): MaybePromise<T> {
4036
+ try {
2860
4037
  return finalize(result, responseSet(ctx))
2861
4038
  } catch (err) {
2862
4039
  return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse)
2863
4040
  }
2864
4041
  }
2865
4042
 
4043
+ private async continueLifecycleAfterDerive<T>(
4044
+ entry: RouteEntry,
4045
+ ctx: RawContext,
4046
+ finalize: (result: unknown, set: CtxSet) => T,
4047
+ wrapResponse: (response: Response) => T,
4048
+ firstIndex: number,
4049
+ first: Promise<unknown>,
4050
+ ): Promise<T> {
4051
+ try {
4052
+ Object.assign(ctx, await first)
4053
+ for (let i = firstIndex + 1; i < entry.derives.length; i++) {
4054
+ const extension = entry.derives[i]!(ctx)
4055
+ Object.assign(ctx, extension instanceof Promise ? await extension : extension)
4056
+ }
4057
+ return await this.runLifecycleHooksAsync(entry, ctx, finalize, wrapResponse)
4058
+ } catch (err) {
4059
+ return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse)
4060
+ }
4061
+ }
4062
+
4063
+ private async runLifecycleHooksAsync<T>(
4064
+ entry: RouteEntry,
4065
+ ctx: RawContext,
4066
+ finalize: (result: unknown, set: CtxSet) => T,
4067
+ wrapResponse: (response: Response) => T,
4068
+ ): Promise<T> {
4069
+ try {
4070
+ for (const hook of entry.beforeHandle) {
4071
+ const outcome = hook(ctx)
4072
+ const early = outcome instanceof Promise ? await outcome : outcome
4073
+ if (early !== undefined) return finalize(early, responseSet(ctx))
4074
+ }
4075
+ return await this.runLifecycleHandlerAsync(entry, ctx, finalize, wrapResponse)
4076
+ } catch (err) {
4077
+ return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse)
4078
+ }
4079
+ }
4080
+
4081
+ private async continueLifecycleAfterBefore<T>(
4082
+ entry: RouteEntry,
4083
+ ctx: RawContext,
4084
+ finalize: (result: unknown, set: CtxSet) => T,
4085
+ wrapResponse: (response: Response) => T,
4086
+ firstIndex: number,
4087
+ first: Promise<unknown>,
4088
+ ): Promise<T> {
4089
+ try {
4090
+ const firstOutcome = await first
4091
+ if (firstOutcome !== undefined) return finalize(firstOutcome, responseSet(ctx))
4092
+ for (let i = firstIndex + 1; i < entry.beforeHandle.length; i++) {
4093
+ const outcome = entry.beforeHandle[i]!(ctx)
4094
+ const early = outcome instanceof Promise ? await outcome : outcome
4095
+ if (early !== undefined) return finalize(early, responseSet(ctx))
4096
+ }
4097
+ return await this.runLifecycleHandlerAsync(entry, ctx, finalize, wrapResponse)
4098
+ } catch (err) {
4099
+ return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse)
4100
+ }
4101
+ }
4102
+
4103
+ private async runLifecycleHandlerAsync<T>(
4104
+ entry: RouteEntry,
4105
+ ctx: RawContext,
4106
+ finalize: (result: unknown, set: CtxSet) => T,
4107
+ wrapResponse: (response: Response) => T,
4108
+ ): Promise<T> {
4109
+ try {
4110
+ const output = entry.handler(ctx)
4111
+ const result = output instanceof Promise ? await output : output
4112
+ return await this.finishLifecycleResult(entry, ctx, finalize, wrapResponse, result)
4113
+ } catch (err) {
4114
+ return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse)
4115
+ }
4116
+ }
4117
+
4118
+ private finishLifecycleResult<T>(
4119
+ entry: RouteEntry,
4120
+ ctx: RawContext,
4121
+ finalize: (result: unknown, set: CtxSet) => T,
4122
+ wrapResponse: (response: Response) => T,
4123
+ initial: unknown,
4124
+ ): MaybePromise<T> {
4125
+ try {
4126
+ let result = initial
4127
+ for (let i = 0; i < entry.afterHandle.length; i++) {
4128
+ const transformed = entry.afterHandle[i]!(result, ctx)
4129
+ if (transformed instanceof Promise) {
4130
+ return this.continueLifecycleAfterHandle(
4131
+ entry,
4132
+ ctx,
4133
+ finalize,
4134
+ wrapResponse,
4135
+ i,
4136
+ transformed,
4137
+ )
4138
+ }
4139
+ result = transformed
4140
+ }
4141
+ return this.finishLifecycleContract(entry, ctx, finalize, wrapResponse, result)
4142
+ } catch (err) {
4143
+ return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse)
4144
+ }
4145
+ }
4146
+
4147
+ private async continueLifecycleAfterHandle<T>(
4148
+ entry: RouteEntry,
4149
+ ctx: RawContext,
4150
+ finalize: (result: unknown, set: CtxSet) => T,
4151
+ wrapResponse: (response: Response) => T,
4152
+ firstIndex: number,
4153
+ first: Promise<unknown>,
4154
+ ): Promise<T> {
4155
+ try {
4156
+ let result = await first
4157
+ for (let i = firstIndex + 1; i < entry.afterHandle.length; i++) {
4158
+ const transformed = entry.afterHandle[i]!(result, ctx)
4159
+ result = transformed instanceof Promise ? await transformed : transformed
4160
+ }
4161
+ return await this.finishLifecycleContract(entry, ctx, finalize, wrapResponse, result)
4162
+ } catch (err) {
4163
+ return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse)
4164
+ }
4165
+ }
4166
+
4167
+ private finishLifecycleContract<T>(
4168
+ entry: RouteEntry,
4169
+ ctx: RawContext,
4170
+ finalize: (result: unknown, set: CtxSet) => T,
4171
+ wrapResponse: (response: Response) => T,
4172
+ result: unknown,
4173
+ ): MaybePromise<T> {
4174
+ try {
4175
+ const contract = entry.responseContract
4176
+ if (contract === undefined) return finalize(result, responseSet(ctx))
4177
+ const checked = contract.runtime.check(contract.schema, result)
4178
+ if (checked instanceof Promise) {
4179
+ return checked.then(
4180
+ (outcome) => this.finishContractOutcome(ctx, finalize, wrapResponse, outcome),
4181
+ (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse),
4182
+ )
4183
+ }
4184
+ return this.finishContractOutcome(ctx, finalize, wrapResponse, checked)
4185
+ } catch (err) {
4186
+ return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse)
4187
+ }
4188
+ }
4189
+
4190
+ private finishContractOutcome<T>(
4191
+ ctx: RawContext,
4192
+ finalize: (result: unknown, set: CtxSet) => T,
4193
+ wrapResponse: (response: Response) => T,
4194
+ outcome: {
4195
+ readonly kind: "ok" | "warn" | "violation"
4196
+ readonly value?: unknown
4197
+ readonly message?: string
4198
+ },
4199
+ ): T {
4200
+ if (outcome.kind === "violation") {
4201
+ this.logger.error("response contract violation", {
4202
+ method: ctx.req.method,
4203
+ path: pathnameOf(ctx.req.url),
4204
+ detail: outcome.message,
4205
+ })
4206
+ return wrapResponse(jsonError(500, "internal_error"))
4207
+ }
4208
+ if (outcome.kind === "warn") {
4209
+ this.logger.warn("response contract", {
4210
+ method: ctx.req.method,
4211
+ path: pathnameOf(ctx.req.url),
4212
+ detail: outcome.message,
4213
+ })
4214
+ }
4215
+ return finalize(outcome.value, responseSet(ctx))
4216
+ }
4217
+
2866
4218
  private async handleLifecycleError<T>(
2867
4219
  entry: RouteEntry,
2868
4220
  err: unknown,
@@ -2929,12 +4281,12 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2929
4281
 
2930
4282
  /** Apply validation and its recovery hook on the generic lifecycle lane. Recovery is completed
2931
4283
  * before derives/beforeHandle run, matching the body-only and query-only execution lanes. */
2932
- private async applyLifecycleValidation(
4284
+ private applyLifecycleValidation(
2933
4285
  entry: RouteEntry,
2934
4286
  result: StandardResult<unknown>,
2935
4287
  ctx: RawContext,
2936
4288
  kind: "body" | "query" | "params",
2937
- ): Promise<Response | undefined> {
4289
+ ): MaybePromise<Response | undefined> {
2938
4290
  const assign = (value: unknown): void => {
2939
4291
  if (kind === "body") ctx.body = value
2940
4292
  else if (kind === "query") ctx.query = value
@@ -2947,8 +4299,22 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2947
4299
  const hook = entry.schema?.onValidationError ?? this.defaultOnValidationError
2948
4300
  if (hook === undefined) return validationError(result.issues)
2949
4301
  const attempted = hook(result.issues, ctx as unknown as Context, kind)
2950
- const recovery = attempted instanceof Promise ? await attempted : attempted
2951
- if (recovery === undefined) return validationError(result.issues)
4302
+ if (attempted instanceof Promise) {
4303
+ return attempted.then((recovery) =>
4304
+ this.finishLifecycleValidationRecovery(entry, kind, result.issues!, recovery, assign),
4305
+ )
4306
+ }
4307
+ return this.finishLifecycleValidationRecovery(entry, kind, result.issues, attempted, assign)
4308
+ }
4309
+
4310
+ private finishLifecycleValidationRecovery(
4311
+ entry: RouteEntry,
4312
+ kind: "body" | "query" | "params",
4313
+ issues: ReadonlyArray<StandardIssue>,
4314
+ recovery: unknown,
4315
+ assign: (value: unknown) => void,
4316
+ ): MaybePromise<Response | undefined> {
4317
+ if (recovery === undefined) return validationError(issues)
2952
4318
  if (recovery instanceof Response) return recovery
2953
4319
  const schema =
2954
4320
  kind === "body"
@@ -2957,9 +4323,15 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2957
4323
  ? entry.schema?.query
2958
4324
  : entry.schema?.params
2959
4325
  const retried = schema!["~standard"].validate(recovery)
2960
- const settled = retried instanceof Promise ? await retried : retried
2961
- if (settled.issues !== undefined) return validationError(settled.issues)
2962
- assign(settled.value)
4326
+ if (retried instanceof Promise) {
4327
+ return retried.then((settled) => {
4328
+ if (settled.issues !== undefined) return validationError(settled.issues)
4329
+ assign(settled.value)
4330
+ return undefined
4331
+ })
4332
+ }
4333
+ if (retried.issues !== undefined) return validationError(retried.issues)
4334
+ assign(retried.value)
2963
4335
  return undefined
2964
4336
  }
2965
4337
 
@@ -3075,7 +4447,16 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3075
4447
  )
3076
4448
  count += 1
3077
4449
  }
3078
- return count === 0 ? undefined : routes
4450
+ if (count === 0) return undefined
4451
+ // RFC 9110 §9.3.2: a GET route answers HEAD with identical status + headers (Bun strips the
4452
+ // body on the wire). Alias the compiled GET handler under HEAD so HEAD stays on the native
4453
+ // lane instead of falling through to the portable dispatcher; the router applies the same
4454
+ // fallback there, so both lanes agree. An explicit HEAD registration wins via the ??=.
4455
+ for (const path of Object.keys(routes)) {
4456
+ const methods = routes[path]!
4457
+ if (methods.GET !== undefined) methods.HEAD ??= methods.GET
4458
+ }
4459
+ return routes
3079
4460
  }
3080
4461
 
3081
4462
  /**