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