@nifrajs/core 2.8.1 → 2.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/dist/index.d.ts +1 -1
  2. package/dist/index.js +1 -1
  3. package/dist/internal/route-execution.d.ts +8 -2
  4. package/dist/internal/route-execution.d.ts.map +1 -1
  5. package/dist/node-direct.d.ts.map +1 -1
  6. package/dist/node-direct.js +3 -1
  7. package/dist/node-direct.js.map +1 -1
  8. package/dist/router/router.d.ts.map +1 -1
  9. package/dist/router/router.js +9 -1
  10. package/dist/router/router.js.map +1 -1
  11. package/dist/server/body.d.ts +3 -0
  12. package/dist/server/body.d.ts.map +1 -1
  13. package/dist/server/body.js +9 -0
  14. package/dist/server/body.js.map +1 -1
  15. package/dist/server/context.d.ts +2 -0
  16. package/dist/server/context.d.ts.map +1 -1
  17. package/dist/server/cookies.d.ts.map +1 -1
  18. package/dist/server/cookies.js +4 -1
  19. package/dist/server/cookies.js.map +1 -1
  20. package/dist/server/http.d.ts.map +1 -1
  21. package/dist/server/http.js +16 -3
  22. package/dist/server/http.js.map +1 -1
  23. package/dist/server/node-outcome-hook.d.ts +78 -1
  24. package/dist/server/node-outcome-hook.d.ts.map +1 -1
  25. package/dist/server/node-outcome-hook.js +197 -1
  26. package/dist/server/node-outcome-hook.js.map +1 -1
  27. package/dist/server/node-outcome.d.ts +22 -2
  28. package/dist/server/node-outcome.d.ts.map +1 -1
  29. package/dist/server/node-outcome.js +86 -11
  30. package/dist/server/node-outcome.js.map +1 -1
  31. package/dist/server/query.d.ts +2 -0
  32. package/dist/server/query.d.ts.map +1 -1
  33. package/dist/server/query.js +27 -1
  34. package/dist/server/query.js.map +1 -1
  35. package/dist/server/request-context.d.ts +8 -1
  36. package/dist/server/request-context.d.ts.map +1 -1
  37. package/dist/server/request-context.js +23 -2
  38. package/dist/server/request-context.js.map +1 -1
  39. package/dist/server/respond.d.ts +29 -3
  40. package/dist/server/respond.d.ts.map +1 -1
  41. package/dist/server/respond.js +279 -16
  42. package/dist/server/respond.js.map +1 -1
  43. package/dist/server/response-contract-lane.d.ts +17 -3
  44. package/dist/server/response-contract-lane.d.ts.map +1 -1
  45. package/dist/server/response-contract-lane.js +6 -3
  46. package/dist/server/response-contract-lane.js.map +1 -1
  47. package/dist/server/server-types.d.ts +37 -0
  48. package/dist/server/server-types.d.ts.map +1 -1
  49. package/dist/server/server.d.ts +151 -7
  50. package/dist/server/server.d.ts.map +1 -1
  51. package/dist/server/server.js +1158 -176
  52. package/dist/server/server.js.map +1 -1
  53. package/dist/server/static-headers.d.ts +47 -0
  54. package/dist/server/static-headers.d.ts.map +1 -0
  55. package/dist/server/static-headers.js +109 -0
  56. package/dist/server/static-headers.js.map +1 -0
  57. package/dist/server/webhook.d.ts.map +1 -1
  58. package/dist/server/webhook.js +11 -2
  59. package/dist/server/webhook.js.map +1 -1
  60. package/dist/server.d.ts +2 -1
  61. package/dist/server.d.ts.map +1 -1
  62. package/dist/server.js +1 -0
  63. package/dist/server.js.map +1 -1
  64. package/dist/transport-codec.d.ts +15 -0
  65. package/dist/transport-codec.d.ts.map +1 -1
  66. package/dist/transport-codec.js +49 -10
  67. package/dist/transport-codec.js.map +1 -1
  68. package/package.json +1 -1
  69. package/src/index.ts +1 -1
  70. package/src/internal/route-execution.ts +19 -1
  71. package/src/node-direct.ts +7 -1
  72. package/src/router/router.ts +8 -1
  73. package/src/server/body.ts +10 -0
  74. package/src/server/context.ts +2 -0
  75. package/src/server/cookies.ts +4 -1
  76. package/src/server/http.ts +16 -3
  77. package/src/server/node-outcome-hook.ts +315 -1
  78. package/src/server/node-outcome.ts +100 -14
  79. package/src/server/query.ts +26 -1
  80. package/src/server/request-context.ts +23 -1
  81. package/src/server/respond.ts +386 -17
  82. package/src/server/response-contract-lane.ts +17 -3
  83. package/src/server/server-types.ts +42 -0
  84. package/src/server/server.ts +1615 -239
  85. package/src/server/static-headers.ts +142 -0
  86. package/src/server/webhook.ts +13 -2
  87. package/src/server.ts +9 -0
  88. package/src/transport-codec.ts +55 -10
@@ -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,
@@ -49,12 +50,23 @@ import type {
49
50
  StandardResult,
50
51
  StandardSchemaV1,
51
52
  } from "../schema/standard.ts"
52
- import { parseContentLength } from "./body.ts"
53
+ 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. */
@@ -456,8 +656,12 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
456
656
  this.wsRouter = new Router<WsEntry>()
457
657
  this.wsRouteCount = 0
458
658
  this.topics = undefined
459
- this.maxBodyBytes = options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES
460
- this.wsMaxPayloadBytes = options.wsMaxPayloadBytes ?? this.maxBodyBytes
659
+ const maxBodyBytes = options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES
660
+ assertByteLimit(maxBodyBytes, "maxBodyBytes")
661
+ const wsMaxPayloadBytes = options.wsMaxPayloadBytes ?? maxBodyBytes
662
+ assertByteLimit(wsMaxPayloadBytes, "wsMaxPayloadBytes")
663
+ this.maxBodyBytes = maxBodyBytes
664
+ this.wsMaxPayloadBytes = wsMaxPayloadBytes
461
665
  this.requestTimeoutMs = options.requestTimeoutMs ?? 0
462
666
  this.clientIpTrust = options.clientIp
463
667
  this.acceptInboundDeadlines = options.acceptInboundDeadlines ?? false
@@ -495,9 +699,20 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
495
699
  this.onErrorHooks = []
496
700
  this.aroundHooks = []
497
701
  this.onRequestHooks = []
702
+ this.onNodeRequestHooks = []
703
+ this.nodeRequestHooksComplete = true
498
704
  this.onResponseHooks = []
705
+ this.onNodeResponseHooks = []
706
+ this.nodeResponseHooksComplete = true
499
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()
500
713
  this.responseRequests = new WeakMap()
714
+ this.responseSources = new WeakMap()
715
+ this.nodeContexts = new WeakMap()
501
716
  this.appliedPlugins = new Set()
502
717
  this.activeAssurance = []
503
718
  this.globalAssurance = []
@@ -537,6 +752,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
537
752
  ): this {
538
753
  this.assertConfigurable("onRequest()")
539
754
  this.onRequestHooks.push(fn as RawOnRequest)
755
+ this.onNodeRequestHooks.push(undefined)
756
+ this.nodeRequestHooksComplete = false
540
757
  return this
541
758
  }
542
759
 
@@ -610,6 +827,122 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
610
827
  onResponse(fn: (response: Response, req: Request) => MaybePromise<Response>): this {
611
828
  this.assertConfigurable("onResponse()")
612
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
+ })
613
946
  return this
614
947
  }
615
948
 
@@ -678,11 +1011,31 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
678
1011
  }
679
1012
  this.globalAssurance.push(...evidence.filter((item) => item.scope === "global"))
680
1013
  this.activeAssurance.push(...evidence.filter((item) => item.scope === "subsequent"))
681
- 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
+ }
682
1022
  if (arg.around !== undefined) this.around(arg.around)
683
1023
  if (arg.beforeHandle !== undefined) this.beforeHandle(arg.beforeHandle)
684
1024
  if (arg.afterHandle !== undefined) this.afterHandle(arg.afterHandle)
685
- 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)
686
1039
  if (arg.onResponseFinalized !== undefined) this.onResponseFinalized(arg.onResponseFinalized)
687
1040
  if (arg.onError !== undefined) this.onError(arg.onError)
688
1041
  return this
@@ -1081,7 +1434,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1081
1434
  )
1082
1435
  // A checked response schema needs the handler's VALUE before it becomes bytes, and the fused and
1083
1436
  // native lanes exist precisely to skip that step. So a contracted route leaves them - the same
1084
- // 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).
1085
1440
  const runtime = this.responseContractRuntime
1086
1441
  const contracted =
1087
1442
  runtime !== undefined && schema?.response !== undefined
@@ -1119,6 +1474,33 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1119
1474
  this.afterHandleHooks.length === 0 &&
1120
1475
  this.onErrorHooks.length === 0 &&
1121
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
1122
1504
  const fusedWeb =
1123
1505
  bare && this.aroundHooks.length === 0
1124
1506
  ? this.buildFusedWeb(
@@ -1132,20 +1514,15 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1132
1514
  hasDecorations ? routeDecorations : undefined,
1133
1515
  schema.query as StandardSchemaV1,
1134
1516
  )
1135
- : undefined
1517
+ : fusedBody
1518
+ ? this.buildFusedBodyWeb(fusedBodyRunner as FusedBodyRunner)
1519
+ : undefined
1136
1520
  const contextless = bare && this.aroundHooks.length === 0 && isContextlessNoArgArrow(handler)
1137
1521
  // The body and query lanes finalize the handler's result themselves, so a contracted route takes
1138
1522
  // the general lifecycle lane instead - one place owns the check rather than three.
1139
1523
  const lane = bare
1140
1524
  ? "bare"
1141
- : contracted === undefined &&
1142
- schema?.body !== undefined &&
1143
- schema.query === undefined &&
1144
- schema.params === undefined &&
1145
- this.derives.length === 0 &&
1146
- this.beforeHandleHooks.length === 0 &&
1147
- this.afterHandleHooks.length === 0 &&
1148
- this.onErrorHooks.length === 0
1525
+ : bodyOnly
1149
1526
  ? "body"
1150
1527
  : contracted === undefined &&
1151
1528
  schema?.body === undefined &&
@@ -1157,15 +1534,45 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1157
1534
  this.onErrorHooks.length === 0
1158
1535
  ? "query"
1159
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
1160
1565
  const execution = this.compileExecutionPlan(
1161
1566
  lane,
1162
1567
  contextless,
1163
1568
  this.aroundHooks.length > 0,
1164
1569
  ledgered !== undefined,
1570
+ lifecycleLane,
1165
1571
  fusedWeb,
1166
- fusedWeb === undefined ? undefined : fusedQuery ? "query" : "bare",
1572
+ fusedBodyRunner,
1573
+ fusedWeb === undefined ? undefined : fusedQuery ? "query" : fusedBody ? "body" : "bare",
1167
1574
  )
1168
- const entry: RouteEntry = {
1575
+ const registeredEntry: RouteEntry = {
1169
1576
  // (context: never) => unknown -> InternalHandler: the framework invokes it
1170
1577
  // with the concrete RawContext the typed handler expects, so this is sound.
1171
1578
  handler: handler as unknown as InternalHandler,
@@ -1179,6 +1586,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1179
1586
  beforeHandle: [...this.beforeHandleHooks],
1180
1587
  afterHandle: [...this.afterHandleHooks],
1181
1588
  onError: [...this.onErrorHooks],
1589
+ lifecycleHookLane,
1182
1590
  around: [...this.aroundHooks],
1183
1591
  execution,
1184
1592
  }
@@ -1225,7 +1633,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1225
1633
  method,
1226
1634
  path,
1227
1635
  pattern,
1228
- entry,
1636
+ entry: registeredEntry,
1229
1637
  descriptor,
1230
1638
  assurance: Object.freeze(routeAssurance),
1231
1639
  }
@@ -1238,8 +1646,10 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1238
1646
  contextless: boolean,
1239
1647
  hasAround: boolean,
1240
1648
  hasLedger: boolean,
1649
+ lifecycleLane: "hooks" | "query" | "body" | "body-query" | undefined,
1241
1650
  fusedWeb: FusedWebRunner | undefined,
1242
- fusedLane: "bare" | "query" | undefined,
1651
+ fusedBody: FusedBodyRunner | undefined,
1652
+ fusedLane: "bare" | "body" | "query" | undefined,
1243
1653
  ): RouteExecutionPlan {
1244
1654
  if (contextless) {
1245
1655
  const run: RouteExecutionRunner = (
@@ -1251,6 +1661,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1251
1661
  signal,
1252
1662
  budget,
1253
1663
  platform,
1664
+ _nativeContext,
1254
1665
  finalize,
1255
1666
  wrapResponse,
1256
1667
  ) =>
@@ -1265,7 +1676,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1265
1676
  finalize,
1266
1677
  wrapResponse,
1267
1678
  )
1268
- return Object.freeze({ run, fusedWeb, fusedLane })
1679
+ return Object.freeze({ run, fusedWeb, fusedBody, fusedLane })
1269
1680
  }
1270
1681
 
1271
1682
  let inner: ContextRouteRunner
@@ -1283,8 +1694,27 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1283
1694
  runtime.runQueryOnly(entry, ctx, finalize, wrapResponse)
1284
1695
  break
1285
1696
  default:
1286
- inner = (runtime, entry, source, ctx, finalize, wrapResponse) =>
1287
- 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
+ }
1288
1718
  }
1289
1719
  const execute: ContextRouteRunner = hasAround
1290
1720
  ? (runtime, entry, source, ctx, finalize, wrapResponse) =>
@@ -1305,18 +1735,13 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1305
1735
  signal,
1306
1736
  budget,
1307
1737
  platform,
1738
+ nativeContext,
1308
1739
  finalize,
1309
1740
  wrapResponse,
1310
1741
  ) => {
1311
- const ctx = new RequestContext(
1312
- source,
1313
- params,
1314
- search,
1315
- signal,
1316
- budget,
1317
- platform,
1318
- runtime.maxBodyBytes,
1319
- )
1742
+ const ctx = nativeContext
1743
+ ? RequestContext.native(source, params, search, runtime.maxBodyBytes, platform)
1744
+ : new RequestContext(source, params, search, signal, budget, platform, runtime.maxBodyBytes)
1320
1745
  let ledger: RequestLedger | undefined
1321
1746
  // The runtime is always present when a route resolved a ledger (enforced at registration).
1322
1747
  const ledgerRuntime = runtime.effectLedgerRuntime
@@ -1337,7 +1762,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1337
1762
  }
1338
1763
  return outcome
1339
1764
  }
1340
- return Object.freeze({ run, fusedWeb, fusedLane })
1765
+ return Object.freeze({ run, fusedWeb, fusedBody, fusedLane })
1341
1766
  }
1342
1767
 
1343
1768
  /** The idempotency lane's bridge back into the normal matched lanes, resolved to a concrete Response
@@ -1356,9 +1781,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1356
1781
  entry,
1357
1782
  params,
1358
1783
  search,
1359
- toResponse,
1360
- IDENTITY_RESPONSE,
1361
- RESPONSE_TIMEOUT,
1784
+ this.finalizeResponse,
1785
+ this.wrapWebResponse,
1786
+ this.webResponseTimeout,
1362
1787
  false,
1363
1788
  ),
1364
1789
  )
@@ -1442,8 +1867,23 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1442
1867
  this.sseRuntime ??= source.sseRuntime
1443
1868
  this.wsRuntime ??= source.wsRuntime
1444
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
+ }
1445
1878
  this.onResponseHooks.push(...source.onResponseHooks)
1879
+ this.onNodeResponseHooks.push(...source.onNodeResponseHooks)
1880
+ this.nodeResponseHooksComplete &&= source.nodeResponseHooksComplete
1446
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
+ }
1447
1887
  this.globalAssurance.push(...source.globalAssurance)
1448
1888
  this.mcpResourceList.push(...source.mcpResourceList)
1449
1889
  this.mcpPromptList.push(...source.mcpPromptList)
@@ -1457,23 +1897,33 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1457
1897
  if (entry.execution.fusedWeb === undefined) return route
1458
1898
  // Rebuild with the SAME builder that produced the closure - rebinding a query-fused route as
1459
1899
  // bare would silently drop its validation.
1460
- const fusedWeb =
1461
- entry.execution.fusedLane === "query"
1462
- ? this.buildFusedQueryWeb(
1900
+ const fusedBody =
1901
+ entry.execution.fusedLane === "body"
1902
+ ? this.buildFusedBodyRunner(
1463
1903
  entry.handler,
1904
+ entry.schema?.body as StandardSchemaV1,
1464
1905
  entry.hasDecorations ? entry.decorations : undefined,
1465
- entry.schema?.query as StandardSchemaV1,
1466
- )
1467
- : this.buildFusedWeb(
1468
- entry.handler,
1469
- entry.hasDecorations ? entry.decorations : undefined,
1470
- isContextlessNoArgArrow(entry.handler),
1471
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
+ )
1472
1922
  return {
1473
1923
  ...route,
1474
1924
  entry: {
1475
1925
  ...entry,
1476
- execution: Object.freeze({ ...entry.execution, fusedWeb }),
1926
+ execution: Object.freeze({ ...entry.execution, fusedWeb, fusedBody }),
1477
1927
  },
1478
1928
  }
1479
1929
  }
@@ -1526,9 +1976,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1526
1976
  const outcome = this.dispatch<Response>(
1527
1977
  source,
1528
1978
  platform,
1529
- toResponse,
1530
- IDENTITY_RESPONSE,
1531
- RESPONSE_TIMEOUT,
1979
+ this.finalizeResponse,
1980
+ this.wrapWebResponse,
1981
+ this.webResponseTimeout,
1532
1982
  true,
1533
1983
  )
1534
1984
  if (this.onResponseHooks.length === 0 && this.onResponseFinalizedHooks.length === 0) {
@@ -1565,9 +2015,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1565
2015
  entry,
1566
2016
  params,
1567
2017
  undefined,
1568
- toResponse,
1569
- IDENTITY_RESPONSE,
1570
- RESPONSE_TIMEOUT,
2018
+ this.finalizeResponse,
2019
+ this.wrapWebResponse,
2020
+ this.webResponseTimeout,
1571
2021
  true,
1572
2022
  )
1573
2023
  if (this.onResponseHooks.length === 0 && this.onResponseFinalizedHooks.length === 0) {
@@ -1621,10 +2071,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1621
2071
  * Like {@link fetch}, but renders a plain-data result **without** building a Web `Response` - the
1622
2072
  * `@nifrajs/node` adapter serializes the returned primitives straight to the socket, skipping the undici
1623
2073
  * `Response` build + body drain (the bulk of the Node bridge cost, measured ≈4µs/req). A handler that
1624
- * returns a `Response`, an error/short-circuit, or any registered `onResponse` hook falls back to the
1625
- * full Web path (`{ kind: "response" }`), so behavior is identical - only the common JSON-data case is
1626
- * faster. Same lifecycle as {@link fetch} (body cap, validation, hooks all run); only the final
1627
- * 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.
1628
2077
  */
1629
2078
  resolveNode(req: Request, platform?: Platform<EnvOf<Ctx>>): MaybePromise<NodeServeOutcome> {
1630
2079
  return this.resolveNodeSource(req, platform)
@@ -1635,13 +2084,21 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1635
2084
  platform?: Platform<EnvOf<Ctx>>,
1636
2085
  suppliedRuntime?: NodeOutcomeRuntime,
1637
2086
  ): MaybePromise<NodeServeOutcome> {
1638
- // onResponse hooks transform a Response, and the capacity gate wraps the Web response path - both
1639
- // force the Web path here (the gated `fetchSource` admits/sheds/releases); wrap its result.
1640
- if (
1641
- this.onResponseHooks.length > 0 ||
1642
- this.onResponseFinalizedHooks.length > 0 ||
1643
- this.capacityGate !== undefined
1644
- ) {
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) {
1645
2102
  const response = this.fetchSource(source, platform)
1646
2103
  return response instanceof Promise
1647
2104
  ? response.then((settled) => ({ kind: "response", response: settled }))
@@ -1657,7 +2114,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1657
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`).",
1658
2115
  )
1659
2116
  }
1660
- return this.dispatch<NodeServeOutcome>(
2117
+ const resolved = this.dispatch<NodeServeOutcome>(
1661
2118
  source,
1662
2119
  platform,
1663
2120
  runtime.toOutcome,
@@ -1665,6 +2122,175 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1665
2122
  runtime.timeout,
1666
2123
  false,
1667
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
+ }
1668
2294
  }
1669
2295
 
1670
2296
  /**
@@ -1795,9 +2421,69 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1795
2421
  if (this.onRequestHooks.length === 0) {
1796
2422
  return this.routeAndRun(source, resolved, finalize, wrapResponse, onTimeout, webFast)
1797
2423
  }
2424
+ if (!webFast && this.canUseNodeRequestHooks()) {
2425
+ return this.runWithNodeRequest(source, resolved, finalize, wrapResponse, onTimeout)
2426
+ }
1798
2427
  return this.runWithOnRequest(source, resolved, finalize, wrapResponse, onTimeout, webFast)
1799
2428
  }
1800
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
+
1801
2487
  /**
1802
2488
  * onRequest short-circuit path. Synchronous as long as every hook returns synchronously (the
1803
2489
  * common case - e.g. CORS returning `undefined` for a non-preflight request): an `async` version
@@ -1815,6 +2501,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1815
2501
  ): MaybePromise<T> {
1816
2502
  const hooks = this.onRequestHooks
1817
2503
  const originalRequest = requestOf(source)
2504
+ this.responseSources.set(source as object, originalRequest)
1818
2505
  let current: RequestSource = source
1819
2506
  for (let i = 0; i < hooks.length; i++) {
1820
2507
  const outcome = (hooks[i] as RawOnRequest)(requestOf(current), platform)
@@ -1875,6 +2562,16 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1875
2562
  }
1876
2563
 
1877
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
+ }
1878
2575
  const request = requestOf(source)
1879
2576
  const rewritten = this.responseRequests.get(request)
1880
2577
  if (rewritten === undefined) return request
@@ -1882,6 +2579,51 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1882
2579
  return rewritten
1883
2580
  }
1884
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
+
1885
2627
  /**
1886
2628
  * Route → build context → run. Synchronous through to the handler for a **bare** route
1887
2629
  * (selected by its compiled plan), so a sync handler produces its result with zero promise allocations;
@@ -1895,8 +2637,45 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1895
2637
  onTimeout: () => T,
1896
2638
  webFast: boolean,
1897
2639
  ): MaybePromise<T> {
1898
- const url = source.urlParts ?? urlPartsOf(source.url)
1899
- 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)
1900
2679
  if (!match.found) {
1901
2680
  if (match.reason === "method-not-allowed") {
1902
2681
  return wrapResponse(
@@ -1918,7 +2697,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1918
2697
  platform,
1919
2698
  match.payload,
1920
2699
  params,
1921
- url.search,
2700
+ search,
1922
2701
  finalize,
1923
2702
  wrapResponse,
1924
2703
  onTimeout,
@@ -2017,6 +2796,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2017
2796
  ? getUnboundedRequestBudget()
2018
2797
  : createRequestBudget({ deadline: admission!.deadline as number, signal })
2019
2798
  const plan = entry.execution
2799
+ const nativeContext = controller === undefined
2020
2800
  const outcome: MaybePromise<T> =
2021
2801
  webFast && plan.fusedWeb !== undefined
2022
2802
  ? (plan.fusedWeb(
@@ -2026,20 +2806,33 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2026
2806
  signal,
2027
2807
  budget,
2028
2808
  platform,
2029
- false,
2809
+ nativeContext,
2030
2810
  ) as MaybePromise<T>)
2031
- : plan.run(
2032
- this,
2033
- entry,
2034
- source,
2035
- params,
2036
- search,
2037
- signal,
2038
- budget,
2039
- platform,
2040
- finalize,
2041
- wrapResponse,
2042
- )
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
+ )
2043
2836
  // The request timeout only bounds work that is actually pending - a synchronous (bare) result is
2044
2837
  // already complete and can't time out, so it's returned as-is (no 503 race, no promise).
2045
2838
  if (controller !== undefined && outcome instanceof Promise) {
@@ -2179,10 +2972,12 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2179
2972
  decorations: Record<PropertyKey, unknown> | undefined,
2180
2973
  contextless: boolean,
2181
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.
2182
2977
  const logError = (err: unknown, ctx: RawContext): Response => {
2183
- if (err instanceof Response) return err
2978
+ if (err instanceof Response) return this.wrapWebResponse(err)
2184
2979
  this.logRequestError(err, ctx)
2185
- return jsonError(500, "internal_error")
2980
+ return this.wrapWebResponse(jsonError(500, "internal_error"))
2186
2981
  }
2187
2982
  if (contextless && decorations === undefined) {
2188
2983
  // `() => ...` can't observe the context - skip allocating one entirely (errors still build
@@ -2196,7 +2991,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2196
2991
  return logError(
2197
2992
  err,
2198
2993
  nativeContext
2199
- ? RequestContext.native(source, params, this.maxBodyBytes)
2994
+ ? RequestContext.native(source, params, search, this.maxBodyBytes, platform)
2200
2995
  : new RequestContext(
2201
2996
  source,
2202
2997
  params,
@@ -2209,29 +3004,31 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2209
3004
  )
2210
3005
  }
2211
3006
  if (result instanceof Promise) {
2212
- return result.then(fusedRespondNoSet, (err) =>
2213
- logError(
2214
- err,
2215
- nativeContext
2216
- ? RequestContext.native(source, params, this.maxBodyBytes)
2217
- : new RequestContext(
2218
- source,
2219
- params,
2220
- search,
2221
- signal,
2222
- budget,
2223
- platform,
2224
- this.maxBodyBytes,
2225
- ),
2226
- ),
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
+ ),
2227
3024
  )
2228
3025
  }
2229
- return fusedRespondNoSet(result)
3026
+ return fusedRespondNoSet(result, this.responseBodyTag, this.staticResponseHeaders)
2230
3027
  }
2231
3028
  }
2232
3029
  return (source, params, search, signal, budget, platform, nativeContext) => {
2233
3030
  const ctx = nativeContext
2234
- ? RequestContext.native(source, params, this.maxBodyBytes)
3031
+ ? RequestContext.native(source, params, search, this.maxBodyBytes, platform)
2235
3032
  : new RequestContext(source, params, search, signal, budget, platform, this.maxBodyBytes)
2236
3033
  if (decorations !== undefined) Object.assign(ctx, decorations)
2237
3034
  let result: unknown
@@ -2242,33 +3039,162 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2242
3039
  }
2243
3040
  if (result instanceof Promise) {
2244
3041
  return result.then(
2245
- (value) => fusedRespond(value, ctx),
3042
+ (value) => fusedRespond(value, ctx, this.responseBodyTag, this.staticResponseHeaders),
2246
3043
  (err) => logError(err, ctx),
2247
3044
  )
2248
3045
  }
2249
- return fusedRespond(result, ctx)
3046
+ return fusedRespond(result, ctx, this.responseBodyTag, this.staticResponseHeaders)
2250
3047
  }
2251
3048
  }
2252
3049
 
2253
- /** The fused Web lane for a route whose only lifecycle step is a query schema: parse + validate +
2254
- * handler + respond in one closure, no lifecycle promise when the validator and handler are sync.
2255
- * Eligibility is decided at registration (see `fusedQuery` in {@link register}); the semantics here
2256
- * are exactly `runQueryOnly`'s for that eligible shape - invalid input returns
2257
- * `validationError(issues)` (recovery hooks disqualify the route from this lane), a thrown
2258
- * `Response` passes through, anything else logs and returns a flat 500, and an async validator
2259
- * falls to a then-chain with the same steps. */
2260
- 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(
2261
3054
  handler: InternalHandler,
3055
+ bodySchema: StandardSchemaV1,
2262
3056
  decorations: Record<PropertyKey, unknown> | undefined,
2263
- querySchema: StandardSchemaV1,
2264
- ): FusedWebRunner {
2265
- const logError = (err: unknown, ctx: RawContext): Response => {
2266
- if (err instanceof Response) return err
2267
- this.logRequestError(err, ctx)
2268
- return jsonError(500, "internal_error")
2269
- }
2270
- const runHandler = (ctx: RawContext, value: unknown): MaybePromise<Response> => {
2271
- 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
2272
3198
  let result: unknown
2273
3199
  try {
2274
3200
  result = handler(ctx)
@@ -2277,15 +3203,15 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2277
3203
  }
2278
3204
  if (result instanceof Promise) {
2279
3205
  return result.then(
2280
- (settled) => fusedRespond(settled, ctx),
3206
+ (settled) => fusedRespond(settled, ctx, this.responseBodyTag, this.staticResponseHeaders),
2281
3207
  (err) => logError(err, ctx),
2282
3208
  )
2283
3209
  }
2284
- return fusedRespond(result, ctx)
3210
+ return fusedRespond(result, ctx, this.responseBodyTag, this.staticResponseHeaders)
2285
3211
  }
2286
3212
  return (source, params, search, signal, budget, platform, nativeContext) => {
2287
3213
  const ctx = nativeContext
2288
- ? RequestContext.native(source, params, this.maxBodyBytes)
3214
+ ? RequestContext.native(source, params, search, this.maxBodyBytes, platform)
2289
3215
  : new RequestContext(source, params, search, signal, budget, platform, this.maxBodyBytes)
2290
3216
  if (decorations !== undefined) Object.assign(ctx, decorations)
2291
3217
  let validation: MaybePromise<StandardResult<unknown>>
@@ -2298,12 +3224,13 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2298
3224
  return validation.then(
2299
3225
  (settled) =>
2300
3226
  settled.issues !== undefined
2301
- ? validationError(settled.issues)
3227
+ ? this.wrapWebResponse(validationError(settled.issues))
2302
3228
  : runHandler(ctx, settled.value),
2303
3229
  (err) => logError(err, ctx),
2304
3230
  )
2305
3231
  }
2306
- if (validation.issues !== undefined) return validationError(validation.issues)
3232
+ if (validation.issues !== undefined)
3233
+ return this.wrapWebResponse(validationError(validation.issues))
2307
3234
  return runHandler(ctx, validation.value)
2308
3235
  }
2309
3236
  }
@@ -2356,26 +3283,36 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2356
3283
  ctx: RawContext,
2357
3284
  finalize: (result: unknown, set: CtxSet) => T,
2358
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>,
2359
3302
  ): Promise<T> {
2360
3303
  const contentType = headerOf(source, "content-type") ?? ""
2361
3304
  if (contentType !== "application/json" && !contentType.includes("application/json")) {
2362
3305
  if (isUrlEncodedForm(contentType)) {
2363
- // HTML form posts validate through the same schema as JSON (parity with Elysia/Hono).
2364
- // Off the JSON inline lane on purpose: forms are a fraction of API traffic, and the
2365
- // bounded form reader shares the streaming guard. JSON requests are untouched.
2366
- return readBoundedForm(source, this.maxBodyBytes).then((form) => {
2367
- if (form instanceof Response) return wrapResponse(form)
2368
- return this.finishBodyOnly(entry, form, ctx, finalize, wrapResponse) as Promise<T> | T
2369
- }) 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>
2370
3310
  }
2371
3311
  return Promise.resolve(wrapResponse(jsonError(415, "unsupported_media_type")))
2372
3312
  }
2373
3313
 
2374
- // Inline fast path (profiled): a framed, in-cap, non-chunked body parses with the native
2375
- // `req.json()` directly - one promise, one `.then` closure per request. The generic
2376
- // `readBoundedJson` (an extra async-fn frame + per-request `finish`/`applyValidation`
2377
- // closures) is kept for the chunked / length-less / oversized cases it exists for.
2378
- // 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.
2379
3316
  const declared = headerOf(source, "content-length")
2380
3317
  if (declared !== null) {
2381
3318
  const length = parseContentLength(declared)
@@ -2386,25 +3323,17 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2386
3323
  return Promise.resolve(wrapResponse(jsonError(413, "payload_too_large")))
2387
3324
  }
2388
3325
  if (headerOf(source, "transfer-encoding") === null) {
2389
- return source.json().then(
2390
- (parsed) => this.finishBodyOnly(entry, parsed, ctx, finalize, wrapResponse),
2391
- // Native json() rejection = malformed JSON; anything past parse goes through
2392
- // finishBodyOnly's own error handling.
2393
- () => wrapResponse(jsonError(400, "invalid_json")),
2394
- )
3326
+ return source.json().then(onParsed, () => wrapResponse(jsonError(400, "invalid_json")))
2395
3327
  }
2396
3328
  }
2397
3329
 
2398
3330
  try {
2399
3331
  return this.readBoundedJson(source).then(
2400
- (parsed) => {
2401
- if (parsed instanceof Response) return wrapResponse(parsed)
2402
- return this.finishBodyOnly(entry, parsed, ctx, finalize, wrapResponse)
2403
- },
2404
- (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse),
3332
+ (parsed) => (parsed instanceof Response ? wrapResponse(parsed) : onParsed(parsed)),
3333
+ onError,
2405
3334
  )
2406
3335
  } catch (err) {
2407
- return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse)
3336
+ return Promise.resolve(onError(err))
2408
3337
  }
2409
3338
  }
2410
3339
 
@@ -2678,14 +3607,18 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2678
3607
  response: Response,
2679
3608
  error: unknown,
2680
3609
  req: Request,
2681
- ): Promise<never> | never {
3610
+ ): Promise<never> {
2682
3611
  const notified = this.notifyResponseFinalized({ response, error }, req)
2683
3612
  if (notified instanceof Promise) {
2684
3613
  return notified.then(() => {
2685
3614
  throw error
2686
3615
  })
2687
3616
  }
2688
- 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)
2689
3622
  }
2690
3623
 
2691
3624
  /** Notify terminal observers in order while isolating both sync and async failures. */
@@ -2756,109 +3689,532 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2756
3689
  * Generic over the render: a success value goes through `finalize(result, set)` (a Web `Response`, or
2757
3690
  * node-direct primitives); an early/error `Response` (validation, thrown, 500) through `wrapResponse`.
2758
3691
  */
2759
- private async runLifecycle<T>(
3692
+ /** Synchronous until a validator, lifecycle hook, handler, or contract check actually returns a Promise. */
3693
+ private runLifecycle<T>(
2760
3694
  entry: RouteEntry,
2761
3695
  source: RequestSource,
2762
3696
  ctx: RawContext,
2763
3697
  finalize: (result: unknown, set: CtxSet) => T,
2764
3698
  wrapResponse: (response: Response) => T,
2765
- ): Promise<T> {
3699
+ ): MaybePromise<T> {
2766
3700
  try {
2767
- if (entry.schema?.params !== undefined) {
2768
- // Path params arrive as strings from routing; validate (and coerce) them at the boundary, before
2769
- // the body/query, so a malformed `:id` is a 422 before any work runs. Raw `.validate` (no wrapper
2770
- // alloc), same recovery-hook path as body/query.
2771
- const validation = entry.schema.params["~standard"].validate(ctx.params)
2772
- const result = validation instanceof Promise ? await validation : validation
2773
- const paramsError = await this.applyLifecycleValidation(entry, result, ctx, "params")
2774
- if (paramsError !== undefined) return wrapResponse(paramsError)
2775
- }
2776
- if (entry.schema?.body !== undefined) {
2777
- const bodyError = await this.readAndValidateBody(source, entry, ctx)
2778
- if (bodyError !== undefined) return wrapResponse(bodyError)
2779
- }
2780
- if (entry.schema?.query !== undefined) {
2781
- // Repeated query keys promote to `string[]` (so an array schema validates `?a=1&a=2`); a
2782
- // single occurrence stays a string. Raw `.validate` (read `.issues`/`.value`) skips the
2783
- // validateStandard wrapper alloc.
2784
- const validation = entry.schema.query["~standard"].validate(
2785
- queryObjectOf(ctx[CONTEXT_SEARCH]),
2786
- )
2787
- const result = validation instanceof Promise ? await validation : validation
2788
- const queryError = await this.applyLifecycleValidation(entry, result, ctx, "query")
2789
- if (queryError !== undefined) return wrapResponse(queryError)
2790
- }
2791
-
2792
- // Context extensions: static decorations, then per-request derives.
2793
- // Each hook below only awaits when it actually returns a Promise - a sync hook
2794
- // skips the microtask tick (a fast-path; ~64 ns/hook).
2795
- if (entry.hasDecorations) Object.assign(ctx, entry.decorations) // skip the no-op on bare routes
2796
- // The `.length` guards skip iterator setup for the common no-hook route (most
2797
- // routes have neither derives nor before/after hooks); a hooked route pays only
2798
- // the length check.
2799
- if (entry.derives.length > 0) {
2800
- for (const derive of entry.derives) {
2801
- const extension = derive(ctx)
2802
- 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
+ )
2803
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)
2804
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
+ }
2805
3739
 
2806
- // beforeHandle: a non-undefined return short-circuits, skipping the handler.
2807
- if (entry.beforeHandle.length > 0) {
2808
- for (const hook of entry.beforeHandle) {
2809
- const outcome = hook(ctx)
2810
- const early = outcome instanceof Promise ? await outcome : outcome
2811
- if (early !== undefined) return finalize(early, responseSet(ctx))
2812
- }
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
+ )
2813
3764
  }
3765
+ return this.runQueryAndLifecycleResult(entry, ctx, finalize, wrapResponse, validation)
3766
+ } catch (err) {
3767
+ return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse)
3768
+ }
3769
+ }
2814
3770
 
2815
- const handlerOutput = entry.handler(ctx)
2816
- let result = handlerOutput instanceof Promise ? await handlerOutput : handlerOutput
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
+ }
2817
3787
 
2818
- // afterHandle: transform the result before serialization.
2819
- if (entry.afterHandle.length > 0) {
2820
- for (const hook of entry.afterHandle) {
2821
- const transformed = hook(result, ctx)
2822
- result = transformed instanceof Promise ? await transformed : transformed
2823
- }
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
+ }
3804
+
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
+ )
2824
3938
  }
3939
+ return this.runLifecycleAfterQuery(entry, ctx, finalize, wrapResponse, queryError)
3940
+ } catch (err) {
3941
+ return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse)
3942
+ }
3943
+ }
2825
3944
 
2826
- // The response contract is checked LAST, after afterHandle, so it holds what actually ships
2827
- // rather than what the handler happened to return. Only reached by a contracted route: the
2828
- // registration decision above kept those off the fused/native lanes for exactly this step.
2829
- const contract = entry.responseContract
2830
- if (contract !== undefined) {
2831
- const checked = contract.runtime.check(contract.schema, result)
2832
- const outcome = checked instanceof Promise ? await checked : checked
2833
- if (outcome.kind === "violation") {
2834
- // The handler broke a contract it declared itself, so this is a server fault, not the
2835
- // caller's. Fail closed: the undeclared payload is never sent, and the detail goes to the
2836
- // (redacting) logger rather than to the client.
2837
- this.logger.error("response contract violation", {
2838
- method: ctx.req.method,
2839
- path: pathnameOf(ctx.req.url),
2840
- // `detail`, not `message`: the logger uses `message` for its own first argument, so a
2841
- // field of that name is silently overwritten and the diagnosis disappears.
2842
- detail: outcome.message,
2843
- })
2844
- 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)
2845
3971
  }
2846
- if (outcome.kind === "warn") {
2847
- this.logger.warn("response contract", {
2848
- method: ctx.req.method,
2849
- path: pathnameOf(ctx.req.url),
2850
- detail: outcome.message,
2851
- })
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)
2852
3978
  }
2853
- result = outcome.value
3979
+ if (outcome !== undefined) return finalize(outcome, responseSet(ctx))
2854
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
+ )
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)
2855
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
+ }
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 {
2856
4037
  return finalize(result, responseSet(ctx))
2857
4038
  } catch (err) {
2858
4039
  return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse)
2859
4040
  }
2860
4041
  }
2861
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
+
2862
4218
  private async handleLifecycleError<T>(
2863
4219
  entry: RouteEntry,
2864
4220
  err: unknown,
@@ -2925,12 +4281,12 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2925
4281
 
2926
4282
  /** Apply validation and its recovery hook on the generic lifecycle lane. Recovery is completed
2927
4283
  * before derives/beforeHandle run, matching the body-only and query-only execution lanes. */
2928
- private async applyLifecycleValidation(
4284
+ private applyLifecycleValidation(
2929
4285
  entry: RouteEntry,
2930
4286
  result: StandardResult<unknown>,
2931
4287
  ctx: RawContext,
2932
4288
  kind: "body" | "query" | "params",
2933
- ): Promise<Response | undefined> {
4289
+ ): MaybePromise<Response | undefined> {
2934
4290
  const assign = (value: unknown): void => {
2935
4291
  if (kind === "body") ctx.body = value
2936
4292
  else if (kind === "query") ctx.query = value
@@ -2943,8 +4299,22 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2943
4299
  const hook = entry.schema?.onValidationError ?? this.defaultOnValidationError
2944
4300
  if (hook === undefined) return validationError(result.issues)
2945
4301
  const attempted = hook(result.issues, ctx as unknown as Context, kind)
2946
- const recovery = attempted instanceof Promise ? await attempted : attempted
2947
- 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)
2948
4318
  if (recovery instanceof Response) return recovery
2949
4319
  const schema =
2950
4320
  kind === "body"
@@ -2953,9 +4323,15 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2953
4323
  ? entry.schema?.query
2954
4324
  : entry.schema?.params
2955
4325
  const retried = schema!["~standard"].validate(recovery)
2956
- const settled = retried instanceof Promise ? await retried : retried
2957
- if (settled.issues !== undefined) return validationError(settled.issues)
2958
- 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)
2959
4335
  return undefined
2960
4336
  }
2961
4337