@nifrajs/core 2.8.1 → 2.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +9 -1
- package/dist/router/router.js.map +1 -1
- package/dist/server/body.d.ts +3 -0
- package/dist/server/body.d.ts.map +1 -1
- package/dist/server/body.js +9 -0
- package/dist/server/body.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 +1158 -176
- 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/webhook.d.ts.map +1 -1
- package/dist/server/webhook.js +11 -2
- package/dist/server/webhook.js.map +1 -1
- 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 +8 -1
- package/src/server/body.ts +10 -0
- 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 +1615 -239
- package/src/server/static-headers.ts +142 -0
- package/src/server/webhook.ts +13 -2
- 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,
|
|
@@ -49,12 +50,23 @@ import type {
|
|
|
49
50
|
StandardResult,
|
|
50
51
|
StandardSchemaV1,
|
|
51
52
|
} from "../schema/standard.ts"
|
|
52
|
-
import { parseContentLength } from "./body.ts"
|
|
53
|
+
import { assertByteLimit, parseContentLength } from "./body.ts"
|
|
53
54
|
import { type ClientIpTrust, resolveClientIp } from "./client-ip.ts"
|
|
54
55
|
import type { Context, Platform, ResponseControls, RouteSchema } from "./context.ts"
|
|
55
56
|
import { jsonError, pathnameOf, type UrlParts, urlPartsOf } from "./http.ts"
|
|
56
|
-
import type
|
|
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. */
|
|
@@ -456,8 +656,12 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
456
656
|
this.wsRouter = new Router<WsEntry>()
|
|
457
657
|
this.wsRouteCount = 0
|
|
458
658
|
this.topics = undefined
|
|
459
|
-
|
|
460
|
-
|
|
659
|
+
const maxBodyBytes = options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES
|
|
660
|
+
assertByteLimit(maxBodyBytes, "maxBodyBytes")
|
|
661
|
+
const wsMaxPayloadBytes = options.wsMaxPayloadBytes ?? maxBodyBytes
|
|
662
|
+
assertByteLimit(wsMaxPayloadBytes, "wsMaxPayloadBytes")
|
|
663
|
+
this.maxBodyBytes = maxBodyBytes
|
|
664
|
+
this.wsMaxPayloadBytes = wsMaxPayloadBytes
|
|
461
665
|
this.requestTimeoutMs = options.requestTimeoutMs ?? 0
|
|
462
666
|
this.clientIpTrust = options.clientIp
|
|
463
667
|
this.acceptInboundDeadlines = options.acceptInboundDeadlines ?? false
|
|
@@ -495,9 +699,20 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
495
699
|
this.onErrorHooks = []
|
|
496
700
|
this.aroundHooks = []
|
|
497
701
|
this.onRequestHooks = []
|
|
702
|
+
this.onNodeRequestHooks = []
|
|
703
|
+
this.nodeRequestHooksComplete = true
|
|
498
704
|
this.onResponseHooks = []
|
|
705
|
+
this.onNodeResponseHooks = []
|
|
706
|
+
this.nodeResponseHooksComplete = true
|
|
499
707
|
this.onResponseFinalizedHooks = []
|
|
708
|
+
this.staticResponseHeaders = undefined
|
|
709
|
+
this.wrapWebResponse = IDENTITY_RESPONSE
|
|
710
|
+
this.webResponseTimeout = RESPONSE_TIMEOUT
|
|
711
|
+
this.responseBodyTag = undefined
|
|
712
|
+
this.responseBodyOwners = new Set()
|
|
500
713
|
this.responseRequests = new WeakMap()
|
|
714
|
+
this.responseSources = new WeakMap()
|
|
715
|
+
this.nodeContexts = new WeakMap()
|
|
501
716
|
this.appliedPlugins = new Set()
|
|
502
717
|
this.activeAssurance = []
|
|
503
718
|
this.globalAssurance = []
|
|
@@ -537,6 +752,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
537
752
|
): this {
|
|
538
753
|
this.assertConfigurable("onRequest()")
|
|
539
754
|
this.onRequestHooks.push(fn as RawOnRequest)
|
|
755
|
+
this.onNodeRequestHooks.push(undefined)
|
|
756
|
+
this.nodeRequestHooksComplete = false
|
|
540
757
|
return this
|
|
541
758
|
}
|
|
542
759
|
|
|
@@ -610,6 +827,122 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
610
827
|
onResponse(fn: (response: Response, req: Request) => MaybePromise<Response>): this {
|
|
611
828
|
this.assertConfigurable("onResponse()")
|
|
612
829
|
this.onResponseHooks.push(fn)
|
|
830
|
+
this.onNodeResponseHooks.push(undefined)
|
|
831
|
+
this.nodeResponseHooksComplete = false
|
|
832
|
+
return this
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
/**
|
|
836
|
+
* Declare response headers with NO per-request decision behind them - and pay nothing for them on
|
|
837
|
+
* the request path.
|
|
838
|
+
*
|
|
839
|
+
* These are not a hook. Because the values are known at wire-up, they are folded into response
|
|
840
|
+
* construction (one prebuilt init for JSON renders, one record merge where the request set its own
|
|
841
|
+
* headers), so an app whose only response middleware is static keeps every fused and native lane -
|
|
842
|
+
* `onResponse`/`onResponseHeaders` disable Bun's fused native routes and, for a full `onResponse`,
|
|
843
|
+
* the Node direct writer. They apply to EVERY response, exactly as a response hook would: success,
|
|
844
|
+
* error, 404/405, timeout, and short-circuit alike.
|
|
845
|
+
*
|
|
846
|
+
* app.responseHeaders({ "x-frame-options": "DENY", "referrer-policy": "no-referrer" })
|
|
847
|
+
*
|
|
848
|
+
* They are DEFAULTS: a value the request itself produced (`c.set.headers`, or a response hook)
|
|
849
|
+
* wins, whatever casing it used. Names are lowercased once here; a non-string value, an invalid
|
|
850
|
+
* name, `__proto__`, or a name the render owns (`content-type`, `content-length`,
|
|
851
|
+
* `transfer-encoding`, `set-cookie`) throws a `TypeError` at wire-up.
|
|
852
|
+
*
|
|
853
|
+
* ORDERING: declarations made before any response hook fold into one static record. One made AFTER
|
|
854
|
+
* a response hook cannot - the hook may already have written that name and must keep winning - so
|
|
855
|
+
* it registers as an ordinary `onResponseHeaders` hook instead, preserving registration order at
|
|
856
|
+
* the cost of the static tier's speed. Declare static headers first.
|
|
857
|
+
*/
|
|
858
|
+
responseHeaders(record: Readonly<Record<string, string>>): this {
|
|
859
|
+
this.assertConfigurable("responseHeaders()")
|
|
860
|
+
return this.addStaticResponseHeaders(normalizeStaticResponseHeaders(record))
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
/** Wire an already-validated lowercase record into the static tier, or - once a dynamic response
|
|
864
|
+
* hook owns part of the header state - into a hook that runs in the right order. */
|
|
865
|
+
private addStaticResponseHeaders(record: Record<string, string>): this {
|
|
866
|
+
if (this.onResponseHooks.length > 0) {
|
|
867
|
+
return this.onResponseHeaders((headers) => {
|
|
868
|
+
for (const name of Object.keys(record)) {
|
|
869
|
+
if (!headers.has(name)) headers.set(name, record[name] as string)
|
|
870
|
+
}
|
|
871
|
+
})
|
|
872
|
+
}
|
|
873
|
+
const merged =
|
|
874
|
+
this.staticResponseHeaders === undefined
|
|
875
|
+
? record
|
|
876
|
+
: { ...this.staticResponseHeaders.record, ...record }
|
|
877
|
+
this.staticResponseHeaders = buildStaticResponseHeaders(merged)
|
|
878
|
+
const statics = this.staticResponseHeaders
|
|
879
|
+
this.wrapWebResponse = (response) => applyStaticResponseHeaders(response, statics)
|
|
880
|
+
this.webResponseTimeout = () => applyStaticResponseHeaders(RESPONSE_TIMEOUT(), statics)
|
|
881
|
+
return this
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
private enableResponseBodyTagging(): object {
|
|
885
|
+
if (this.responseBodyTag === undefined) {
|
|
886
|
+
this.responseBodyTag = Object.freeze({})
|
|
887
|
+
this.responseBodyOwners.add(this.responseBodyTag)
|
|
888
|
+
}
|
|
889
|
+
return this.responseBodyTag
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
/**
|
|
893
|
+
* Register a response transform for raw/streamed Responses. Framework-serialized payloads stay on
|
|
894
|
+
* the body tier; this hook is only entered for untagged Responses (streams, proxied fetches, and
|
|
895
|
+
* framework-generated error responses). A no-op native twin keeps buffered JSON on the direct lane.
|
|
896
|
+
*/
|
|
897
|
+
onResponseRaw(fn: (response: Response, req: Request) => MaybePromise<Response>): this {
|
|
898
|
+
this.assertConfigurable("onResponseRaw()")
|
|
899
|
+
this.enableResponseBodyTagging()
|
|
900
|
+
this.onResponseHooks.push(webResponseRawHook(fn, this.responseBodyOwners))
|
|
901
|
+
this.onNodeResponseHooks.push(() => undefined)
|
|
902
|
+
return this
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
/**
|
|
906
|
+
* Register a PORTABLE header-only response hook - one implementation, fast on every runtime.
|
|
907
|
+
*
|
|
908
|
+
* The hook receives a mutable case-insensitive header view, a minimal request view, and the
|
|
909
|
+
* status. On the Web serving paths it runs inside the normal `onResponse` walk against the
|
|
910
|
+
* response's own `Headers` (mutating in place - no clone). On Node it self-pairs as a native
|
|
911
|
+
* response hook against the outcome's plain header record, so registering one NEVER forces the
|
|
912
|
+
* Node adapter off its direct socket writer the way a full `onResponse(res: Response)` hook does.
|
|
913
|
+
* Prefer this over `onResponse` whenever the middleware only reads/writes headers.
|
|
914
|
+
*/
|
|
915
|
+
onResponseHeaders(fn: ResponseHeadersHook): this {
|
|
916
|
+
this.assertConfigurable("onResponseHeaders()")
|
|
917
|
+
this.onResponseHooks.push(webResponseHeadersHook(fn))
|
|
918
|
+
this.onNodeResponseHooks.push((response, req) =>
|
|
919
|
+
fn(recordHeadersView(response), req, response.status),
|
|
920
|
+
)
|
|
921
|
+
return this
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
/**
|
|
925
|
+
* Register a PORTABLE post-serialization body hook - the payload tier. The hook receives the
|
|
926
|
+
* FINAL framework-serialized bytes (plus the mutable header view and status) and may return
|
|
927
|
+
* replacement bytes; `undefined` keeps the body. On the Node direct writer the bytes come
|
|
928
|
+
* straight off the outcome record; on the Web serving paths they ride the framework-built
|
|
929
|
+
* Response as a tag, so no body stream is ever drained on any runtime. A handler-returned raw
|
|
930
|
+
* `Response` (proxied fetch, SSE, streamed SSR) is skipped by contract - transforming those is
|
|
931
|
+
* what the full `onResponse` hook is for.
|
|
932
|
+
*/
|
|
933
|
+
onResponseBody(fn: ResponseBodyHook): this {
|
|
934
|
+
this.assertConfigurable("onResponseBody()")
|
|
935
|
+
this.enableResponseBodyTagging()
|
|
936
|
+
this.onResponseHooks.push(webResponseBodyHook(fn, this.responseBodyOwners))
|
|
937
|
+
this.onNodeResponseHooks.push((response, req) => {
|
|
938
|
+
const body = response.body
|
|
939
|
+
if (body === null) return undefined
|
|
940
|
+
const out = fn(body, recordHeadersView(response), req, response.status)
|
|
941
|
+
if (out instanceof Promise)
|
|
942
|
+
return out.then((replaced) => applyBodyReplacement(response, replaced))
|
|
943
|
+
applyBodyReplacement(response, out)
|
|
944
|
+
return undefined
|
|
945
|
+
})
|
|
613
946
|
return this
|
|
614
947
|
}
|
|
615
948
|
|
|
@@ -678,11 +1011,31 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
678
1011
|
}
|
|
679
1012
|
this.globalAssurance.push(...evidence.filter((item) => item.scope === "global"))
|
|
680
1013
|
this.activeAssurance.push(...evidence.filter((item) => item.scope === "subsequent"))
|
|
681
|
-
if (arg.onRequest !== undefined)
|
|
1014
|
+
if (arg.onRequest !== undefined) {
|
|
1015
|
+
this.assertConfigurable("onRequest()")
|
|
1016
|
+
this.onRequestHooks.push(arg.onRequest as RawOnRequest)
|
|
1017
|
+
this.onNodeRequestHooks.push(arg.onNodeRequest)
|
|
1018
|
+
if (arg.onNodeRequest === undefined) this.nodeRequestHooksComplete = false
|
|
1019
|
+
} else if (arg.onNodeRequest !== undefined) {
|
|
1020
|
+
throw new TypeError("onNodeRequest() requires a paired onRequest() hook")
|
|
1021
|
+
}
|
|
682
1022
|
if (arg.around !== undefined) this.around(arg.around)
|
|
683
1023
|
if (arg.beforeHandle !== undefined) this.beforeHandle(arg.beforeHandle)
|
|
684
1024
|
if (arg.afterHandle !== undefined) this.afterHandle(arg.afterHandle)
|
|
685
|
-
if (arg.onResponse !== undefined)
|
|
1025
|
+
if (arg.onResponse !== undefined) {
|
|
1026
|
+
this.assertConfigurable("onResponse()")
|
|
1027
|
+
this.onResponseHooks.push(arg.onResponse)
|
|
1028
|
+
this.onNodeResponseHooks.push(arg.onNodeResponse)
|
|
1029
|
+
if (arg.onNodeResponse === undefined) this.nodeResponseHooksComplete = false
|
|
1030
|
+
} else if (arg.onNodeResponse !== undefined) {
|
|
1031
|
+
throw new TypeError("onNodeResponse() requires a paired onResponse() hook")
|
|
1032
|
+
}
|
|
1033
|
+
// Before the bundle's own hooks: a bundle declaring both means its static values are the
|
|
1034
|
+
// defaults its hook may then override, which is the order a single bundle reads in.
|
|
1035
|
+
if (arg.responseHeaders !== undefined) this.responseHeaders(arg.responseHeaders)
|
|
1036
|
+
if (arg.onResponseHeaders !== undefined) this.onResponseHeaders(arg.onResponseHeaders)
|
|
1037
|
+
if (arg.onResponseBody !== undefined) this.onResponseBody(arg.onResponseBody)
|
|
1038
|
+
if (arg.onResponseRaw !== undefined) this.onResponseRaw(arg.onResponseRaw)
|
|
686
1039
|
if (arg.onResponseFinalized !== undefined) this.onResponseFinalized(arg.onResponseFinalized)
|
|
687
1040
|
if (arg.onError !== undefined) this.onError(arg.onError)
|
|
688
1041
|
return this
|
|
@@ -1081,7 +1434,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1081
1434
|
)
|
|
1082
1435
|
// A checked response schema needs the handler's VALUE before it becomes bytes, and the fused and
|
|
1083
1436
|
// native lanes exist precisely to skip that step. So a contracted route leaves them - the same
|
|
1084
|
-
// trade an idempotent route makes above
|
|
1437
|
+
// structural trade an idempotent route makes above. The check itself is not the cost: a compiled
|
|
1438
|
+
// validator measures ~100ns/response, within benchmark noise on any route these lanes already
|
|
1439
|
+
// exclude (middleware, derives, lifecycle hooks).
|
|
1085
1440
|
const runtime = this.responseContractRuntime
|
|
1086
1441
|
const contracted =
|
|
1087
1442
|
runtime !== undefined && schema?.response !== undefined
|
|
@@ -1119,6 +1474,33 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1119
1474
|
this.afterHandleHooks.length === 0 &&
|
|
1120
1475
|
this.onErrorHooks.length === 0 &&
|
|
1121
1476
|
this.aroundHooks.length === 0
|
|
1477
|
+
const bodyOnly =
|
|
1478
|
+
contracted === undefined &&
|
|
1479
|
+
schema?.body !== undefined &&
|
|
1480
|
+
schema.query === undefined &&
|
|
1481
|
+
schema.params === undefined &&
|
|
1482
|
+
this.derives.length === 0 &&
|
|
1483
|
+
this.beforeHandleHooks.length === 0 &&
|
|
1484
|
+
this.afterHandleHooks.length === 0 &&
|
|
1485
|
+
this.onErrorHooks.length === 0
|
|
1486
|
+
const fusedBody =
|
|
1487
|
+
!bare &&
|
|
1488
|
+
bodyOnly &&
|
|
1489
|
+
schema.onValidationError === undefined &&
|
|
1490
|
+
this.defaultOnValidationError === undefined &&
|
|
1491
|
+
idempotent === undefined &&
|
|
1492
|
+
ledgered === undefined &&
|
|
1493
|
+
this.aroundHooks.length === 0
|
|
1494
|
+
// Body-only routes still need an async body read, but eligible routes can compile the validation +
|
|
1495
|
+
// handler continuation once. The runner keeps the bounded parser and all error semantics while
|
|
1496
|
+
// avoiding the generic entry/schema/lifecycle dispatch on Bun, Deno, and Node-direct.
|
|
1497
|
+
const fusedBodyRunner = fusedBody
|
|
1498
|
+
? this.buildFusedBodyRunner(
|
|
1499
|
+
handler as unknown as InternalHandler,
|
|
1500
|
+
schema.body as StandardSchemaV1,
|
|
1501
|
+
hasDecorations ? routeDecorations : undefined,
|
|
1502
|
+
)
|
|
1503
|
+
: undefined
|
|
1122
1504
|
const fusedWeb =
|
|
1123
1505
|
bare && this.aroundHooks.length === 0
|
|
1124
1506
|
? this.buildFusedWeb(
|
|
@@ -1132,20 +1514,15 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1132
1514
|
hasDecorations ? routeDecorations : undefined,
|
|
1133
1515
|
schema.query as StandardSchemaV1,
|
|
1134
1516
|
)
|
|
1135
|
-
:
|
|
1517
|
+
: fusedBody
|
|
1518
|
+
? this.buildFusedBodyWeb(fusedBodyRunner as FusedBodyRunner)
|
|
1519
|
+
: undefined
|
|
1136
1520
|
const contextless = bare && this.aroundHooks.length === 0 && isContextlessNoArgArrow(handler)
|
|
1137
1521
|
// The body and query lanes finalize the handler's result themselves, so a contracted route takes
|
|
1138
1522
|
// the general lifecycle lane instead - one place owns the check rather than three.
|
|
1139
1523
|
const lane = bare
|
|
1140
1524
|
? "bare"
|
|
1141
|
-
:
|
|
1142
|
-
schema?.body !== undefined &&
|
|
1143
|
-
schema.query === undefined &&
|
|
1144
|
-
schema.params === undefined &&
|
|
1145
|
-
this.derives.length === 0 &&
|
|
1146
|
-
this.beforeHandleHooks.length === 0 &&
|
|
1147
|
-
this.afterHandleHooks.length === 0 &&
|
|
1148
|
-
this.onErrorHooks.length === 0
|
|
1525
|
+
: bodyOnly
|
|
1149
1526
|
? "body"
|
|
1150
1527
|
: contracted === undefined &&
|
|
1151
1528
|
schema?.body === undefined &&
|
|
@@ -1157,15 +1534,45 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1157
1534
|
this.onErrorHooks.length === 0
|
|
1158
1535
|
? "query"
|
|
1159
1536
|
: "lifecycle"
|
|
1537
|
+
// Lifecycle routes with no params schema are the common middleware shape: a derive/before hook
|
|
1538
|
+
// plus an optional query or body schema. Select their complete validation stage at registration
|
|
1539
|
+
// so the request path never re-checks params/body presence. Parameter-schema routes retain the
|
|
1540
|
+
// generic lifecycle runner until their more involved recovery matrix is selected explicitly.
|
|
1541
|
+
const lifecycleLane =
|
|
1542
|
+
lane !== "lifecycle" || schema?.params !== undefined
|
|
1543
|
+
? undefined
|
|
1544
|
+
: schema?.body !== undefined
|
|
1545
|
+
? schema.query !== undefined
|
|
1546
|
+
? "body-query"
|
|
1547
|
+
: "body"
|
|
1548
|
+
: schema?.query !== undefined
|
|
1549
|
+
? "query"
|
|
1550
|
+
: "hooks"
|
|
1551
|
+
// The realistic middleware shape is commonly exactly one synchronous-or-async derive followed
|
|
1552
|
+
// by one before hook. Keep the generic runner for every route that can observe decorations,
|
|
1553
|
+
// after hooks, error hooks, or response contracts; this lane only removes the two per-request
|
|
1554
|
+
// hook-loop dispatches and preserves the same async continuations and error handling.
|
|
1555
|
+
const lifecycleHookLane =
|
|
1556
|
+
lane === "lifecycle" &&
|
|
1557
|
+
contracted === undefined &&
|
|
1558
|
+
!hasDecorations &&
|
|
1559
|
+
this.derives.length === 1 &&
|
|
1560
|
+
this.beforeHandleHooks.length === 1 &&
|
|
1561
|
+
this.afterHandleHooks.length === 0 &&
|
|
1562
|
+
this.onErrorHooks.length === 0
|
|
1563
|
+
? "derive-before"
|
|
1564
|
+
: undefined
|
|
1160
1565
|
const execution = this.compileExecutionPlan(
|
|
1161
1566
|
lane,
|
|
1162
1567
|
contextless,
|
|
1163
1568
|
this.aroundHooks.length > 0,
|
|
1164
1569
|
ledgered !== undefined,
|
|
1570
|
+
lifecycleLane,
|
|
1165
1571
|
fusedWeb,
|
|
1166
|
-
|
|
1572
|
+
fusedBodyRunner,
|
|
1573
|
+
fusedWeb === undefined ? undefined : fusedQuery ? "query" : fusedBody ? "body" : "bare",
|
|
1167
1574
|
)
|
|
1168
|
-
const
|
|
1575
|
+
const registeredEntry: RouteEntry = {
|
|
1169
1576
|
// (context: never) => unknown -> InternalHandler: the framework invokes it
|
|
1170
1577
|
// with the concrete RawContext the typed handler expects, so this is sound.
|
|
1171
1578
|
handler: handler as unknown as InternalHandler,
|
|
@@ -1179,6 +1586,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1179
1586
|
beforeHandle: [...this.beforeHandleHooks],
|
|
1180
1587
|
afterHandle: [...this.afterHandleHooks],
|
|
1181
1588
|
onError: [...this.onErrorHooks],
|
|
1589
|
+
lifecycleHookLane,
|
|
1182
1590
|
around: [...this.aroundHooks],
|
|
1183
1591
|
execution,
|
|
1184
1592
|
}
|
|
@@ -1225,7 +1633,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1225
1633
|
method,
|
|
1226
1634
|
path,
|
|
1227
1635
|
pattern,
|
|
1228
|
-
entry,
|
|
1636
|
+
entry: registeredEntry,
|
|
1229
1637
|
descriptor,
|
|
1230
1638
|
assurance: Object.freeze(routeAssurance),
|
|
1231
1639
|
}
|
|
@@ -1238,8 +1646,10 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1238
1646
|
contextless: boolean,
|
|
1239
1647
|
hasAround: boolean,
|
|
1240
1648
|
hasLedger: boolean,
|
|
1649
|
+
lifecycleLane: "hooks" | "query" | "body" | "body-query" | undefined,
|
|
1241
1650
|
fusedWeb: FusedWebRunner | undefined,
|
|
1242
|
-
|
|
1651
|
+
fusedBody: FusedBodyRunner | undefined,
|
|
1652
|
+
fusedLane: "bare" | "body" | "query" | undefined,
|
|
1243
1653
|
): RouteExecutionPlan {
|
|
1244
1654
|
if (contextless) {
|
|
1245
1655
|
const run: RouteExecutionRunner = (
|
|
@@ -1251,6 +1661,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1251
1661
|
signal,
|
|
1252
1662
|
budget,
|
|
1253
1663
|
platform,
|
|
1664
|
+
_nativeContext,
|
|
1254
1665
|
finalize,
|
|
1255
1666
|
wrapResponse,
|
|
1256
1667
|
) =>
|
|
@@ -1265,7 +1676,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1265
1676
|
finalize,
|
|
1266
1677
|
wrapResponse,
|
|
1267
1678
|
)
|
|
1268
|
-
return Object.freeze({ run, fusedWeb, fusedLane })
|
|
1679
|
+
return Object.freeze({ run, fusedWeb, fusedBody, fusedLane })
|
|
1269
1680
|
}
|
|
1270
1681
|
|
|
1271
1682
|
let inner: ContextRouteRunner
|
|
@@ -1283,8 +1694,27 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1283
1694
|
runtime.runQueryOnly(entry, ctx, finalize, wrapResponse)
|
|
1284
1695
|
break
|
|
1285
1696
|
default:
|
|
1286
|
-
|
|
1287
|
-
|
|
1697
|
+
switch (lifecycleLane) {
|
|
1698
|
+
case "hooks":
|
|
1699
|
+
inner = (runtime, entry, _source, ctx, finalize, wrapResponse) =>
|
|
1700
|
+
runtime.runLifecycleHooks(entry, ctx, finalize, wrapResponse)
|
|
1701
|
+
break
|
|
1702
|
+
case "query":
|
|
1703
|
+
inner = (runtime, entry, _source, ctx, finalize, wrapResponse) =>
|
|
1704
|
+
runtime.runLifecycleQuery(entry, ctx, finalize, wrapResponse)
|
|
1705
|
+
break
|
|
1706
|
+
case "body":
|
|
1707
|
+
inner = (runtime, entry, source, ctx, finalize, wrapResponse) =>
|
|
1708
|
+
runtime.runLifecycleBody(entry, source, ctx, finalize, wrapResponse)
|
|
1709
|
+
break
|
|
1710
|
+
case "body-query":
|
|
1711
|
+
inner = (runtime, entry, source, ctx, finalize, wrapResponse) =>
|
|
1712
|
+
runtime.runLifecycleBodyQuery(entry, source, ctx, finalize, wrapResponse)
|
|
1713
|
+
break
|
|
1714
|
+
default:
|
|
1715
|
+
inner = (runtime, entry, source, ctx, finalize, wrapResponse) =>
|
|
1716
|
+
runtime.runLifecycle(entry, source, ctx, finalize, wrapResponse)
|
|
1717
|
+
}
|
|
1288
1718
|
}
|
|
1289
1719
|
const execute: ContextRouteRunner = hasAround
|
|
1290
1720
|
? (runtime, entry, source, ctx, finalize, wrapResponse) =>
|
|
@@ -1305,18 +1735,13 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1305
1735
|
signal,
|
|
1306
1736
|
budget,
|
|
1307
1737
|
platform,
|
|
1738
|
+
nativeContext,
|
|
1308
1739
|
finalize,
|
|
1309
1740
|
wrapResponse,
|
|
1310
1741
|
) => {
|
|
1311
|
-
const ctx =
|
|
1312
|
-
source,
|
|
1313
|
-
params,
|
|
1314
|
-
search,
|
|
1315
|
-
signal,
|
|
1316
|
-
budget,
|
|
1317
|
-
platform,
|
|
1318
|
-
runtime.maxBodyBytes,
|
|
1319
|
-
)
|
|
1742
|
+
const ctx = nativeContext
|
|
1743
|
+
? RequestContext.native(source, params, search, runtime.maxBodyBytes, platform)
|
|
1744
|
+
: new RequestContext(source, params, search, signal, budget, platform, runtime.maxBodyBytes)
|
|
1320
1745
|
let ledger: RequestLedger | undefined
|
|
1321
1746
|
// The runtime is always present when a route resolved a ledger (enforced at registration).
|
|
1322
1747
|
const ledgerRuntime = runtime.effectLedgerRuntime
|
|
@@ -1337,7 +1762,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1337
1762
|
}
|
|
1338
1763
|
return outcome
|
|
1339
1764
|
}
|
|
1340
|
-
return Object.freeze({ run, fusedWeb, fusedLane })
|
|
1765
|
+
return Object.freeze({ run, fusedWeb, fusedBody, fusedLane })
|
|
1341
1766
|
}
|
|
1342
1767
|
|
|
1343
1768
|
/** The idempotency lane's bridge back into the normal matched lanes, resolved to a concrete Response
|
|
@@ -1356,9 +1781,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1356
1781
|
entry,
|
|
1357
1782
|
params,
|
|
1358
1783
|
search,
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1784
|
+
this.finalizeResponse,
|
|
1785
|
+
this.wrapWebResponse,
|
|
1786
|
+
this.webResponseTimeout,
|
|
1362
1787
|
false,
|
|
1363
1788
|
),
|
|
1364
1789
|
)
|
|
@@ -1442,8 +1867,23 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1442
1867
|
this.sseRuntime ??= source.sseRuntime
|
|
1443
1868
|
this.wsRuntime ??= source.wsRuntime
|
|
1444
1869
|
this.onRequestHooks.push(...source.onRequestHooks)
|
|
1870
|
+
this.onNodeRequestHooks.push(...source.onNodeRequestHooks)
|
|
1871
|
+
this.nodeRequestHooksComplete &&= source.nodeRequestHooksComplete
|
|
1872
|
+
// The group's static declarations came before its own response hooks, so they are folded in
|
|
1873
|
+
// first - and fold themselves into a hook here if this server already has one (same ordering
|
|
1874
|
+
// rule as a direct `responseHeaders()` call).
|
|
1875
|
+
if (source.staticResponseHeaders !== undefined) {
|
|
1876
|
+
this.addStaticResponseHeaders({ ...source.staticResponseHeaders.record })
|
|
1877
|
+
}
|
|
1445
1878
|
this.onResponseHooks.push(...source.onResponseHooks)
|
|
1879
|
+
this.onNodeResponseHooks.push(...source.onNodeResponseHooks)
|
|
1880
|
+
this.nodeResponseHooksComplete &&= source.nodeResponseHooksComplete
|
|
1446
1881
|
this.onResponseFinalizedHooks.push(...source.onResponseFinalizedHooks)
|
|
1882
|
+
if (source.responseBodyTag !== undefined) {
|
|
1883
|
+
const owner = this.enableResponseBodyTagging()
|
|
1884
|
+
this.responseBodyOwners.add(source.responseBodyTag)
|
|
1885
|
+
source.responseBodyOwners.add(owner)
|
|
1886
|
+
}
|
|
1447
1887
|
this.globalAssurance.push(...source.globalAssurance)
|
|
1448
1888
|
this.mcpResourceList.push(...source.mcpResourceList)
|
|
1449
1889
|
this.mcpPromptList.push(...source.mcpPromptList)
|
|
@@ -1457,23 +1897,33 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1457
1897
|
if (entry.execution.fusedWeb === undefined) return route
|
|
1458
1898
|
// Rebuild with the SAME builder that produced the closure - rebinding a query-fused route as
|
|
1459
1899
|
// bare would silently drop its validation.
|
|
1460
|
-
const
|
|
1461
|
-
entry.execution.fusedLane === "
|
|
1462
|
-
? this.
|
|
1900
|
+
const fusedBody =
|
|
1901
|
+
entry.execution.fusedLane === "body"
|
|
1902
|
+
? this.buildFusedBodyRunner(
|
|
1463
1903
|
entry.handler,
|
|
1904
|
+
entry.schema?.body as StandardSchemaV1,
|
|
1464
1905
|
entry.hasDecorations ? entry.decorations : undefined,
|
|
1465
|
-
entry.schema?.query as StandardSchemaV1,
|
|
1466
|
-
)
|
|
1467
|
-
: this.buildFusedWeb(
|
|
1468
|
-
entry.handler,
|
|
1469
|
-
entry.hasDecorations ? entry.decorations : undefined,
|
|
1470
|
-
isContextlessNoArgArrow(entry.handler),
|
|
1471
1906
|
)
|
|
1907
|
+
: undefined
|
|
1908
|
+
const fusedWeb =
|
|
1909
|
+
entry.execution.fusedLane === "body"
|
|
1910
|
+
? this.buildFusedBodyWeb(fusedBody as FusedBodyRunner)
|
|
1911
|
+
: entry.execution.fusedLane === "query"
|
|
1912
|
+
? this.buildFusedQueryWeb(
|
|
1913
|
+
entry.handler,
|
|
1914
|
+
entry.hasDecorations ? entry.decorations : undefined,
|
|
1915
|
+
entry.schema?.query as StandardSchemaV1,
|
|
1916
|
+
)
|
|
1917
|
+
: this.buildFusedWeb(
|
|
1918
|
+
entry.handler,
|
|
1919
|
+
entry.hasDecorations ? entry.decorations : undefined,
|
|
1920
|
+
isContextlessNoArgArrow(entry.handler),
|
|
1921
|
+
)
|
|
1472
1922
|
return {
|
|
1473
1923
|
...route,
|
|
1474
1924
|
entry: {
|
|
1475
1925
|
...entry,
|
|
1476
|
-
execution: Object.freeze({ ...entry.execution, fusedWeb }),
|
|
1926
|
+
execution: Object.freeze({ ...entry.execution, fusedWeb, fusedBody }),
|
|
1477
1927
|
},
|
|
1478
1928
|
}
|
|
1479
1929
|
}
|
|
@@ -1526,9 +1976,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1526
1976
|
const outcome = this.dispatch<Response>(
|
|
1527
1977
|
source,
|
|
1528
1978
|
platform,
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1979
|
+
this.finalizeResponse,
|
|
1980
|
+
this.wrapWebResponse,
|
|
1981
|
+
this.webResponseTimeout,
|
|
1532
1982
|
true,
|
|
1533
1983
|
)
|
|
1534
1984
|
if (this.onResponseHooks.length === 0 && this.onResponseFinalizedHooks.length === 0) {
|
|
@@ -1565,9 +2015,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1565
2015
|
entry,
|
|
1566
2016
|
params,
|
|
1567
2017
|
undefined,
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
2018
|
+
this.finalizeResponse,
|
|
2019
|
+
this.wrapWebResponse,
|
|
2020
|
+
this.webResponseTimeout,
|
|
1571
2021
|
true,
|
|
1572
2022
|
)
|
|
1573
2023
|
if (this.onResponseHooks.length === 0 && this.onResponseFinalizedHooks.length === 0) {
|
|
@@ -1621,10 +2071,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1621
2071
|
* Like {@link fetch}, but renders a plain-data result **without** building a Web `Response` - the
|
|
1622
2072
|
* `@nifrajs/node` adapter serializes the returned primitives straight to the socket, skipping the undici
|
|
1623
2073
|
* `Response` build + body drain (the bulk of the Node bridge cost, measured ≈4µs/req). A handler that
|
|
1624
|
-
* returns a `Response`, an error/short-circuit, or
|
|
1625
|
-
* full Web path
|
|
1626
|
-
*
|
|
1627
|
-
* render differs.
|
|
2074
|
+
* returns a `Response`, an error/short-circuit, or a response hook that replaces/consumes the buffered
|
|
2075
|
+
* body stays on the full Web path; an in-place response hook can still use the direct writer. Same
|
|
2076
|
+
* lifecycle as {@link fetch} (body cap, validation, hooks all run); only the final render differs.
|
|
1628
2077
|
*/
|
|
1629
2078
|
resolveNode(req: Request, platform?: Platform<EnvOf<Ctx>>): MaybePromise<NodeServeOutcome> {
|
|
1630
2079
|
return this.resolveNodeSource(req, platform)
|
|
@@ -1635,13 +2084,21 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1635
2084
|
platform?: Platform<EnvOf<Ctx>>,
|
|
1636
2085
|
suppliedRuntime?: NodeOutcomeRuntime,
|
|
1637
2086
|
): MaybePromise<NodeServeOutcome> {
|
|
1638
|
-
//
|
|
1639
|
-
//
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
2087
|
+
// A paired header-only native hook can preserve the Node-direct JSON/body outcome for successful
|
|
2088
|
+
// responses. Arbitrary onResponse transforms need a real Web Response, but a buffered outcome can
|
|
2089
|
+
// be materialized with a direct-write marker and return to the socket path when the hook mutates
|
|
2090
|
+
// it in place. Finalization observers and the capacity gate still wrap the complete Web path.
|
|
2091
|
+
// The native response lane engages only when the REQUEST side is native too (or there are no
|
|
2092
|
+
// request hooks at all). This is what makes the NodeRequestContext identity contract hold: a
|
|
2093
|
+
// web request-hook walk can rewrite the request, so its response-side view is a synthetic
|
|
2094
|
+
// wrapper - a different object - and any middleware carrying per-request state from its request
|
|
2095
|
+
// twin to its response twin through a WeakMap would silently miss. Coupling the gates means a
|
|
2096
|
+
// response twin always sees the exact object its request twin saw.
|
|
2097
|
+
const nativeResponseHooks =
|
|
2098
|
+
this.canUseNodeResponseHooks() &&
|
|
2099
|
+
(this.onRequestHooks.length === 0 || this.canUseNodeRequestHooks())
|
|
2100
|
+
const webResponseHooks = this.onResponseHooks.length > 0 && !nativeResponseHooks
|
|
2101
|
+
if (this.onResponseFinalizedHooks.length > 0 || this.capacityGate !== undefined) {
|
|
1645
2102
|
const response = this.fetchSource(source, platform)
|
|
1646
2103
|
return response instanceof Promise
|
|
1647
2104
|
? response.then((settled) => ({ kind: "response", response: settled }))
|
|
@@ -1657,7 +2114,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1657
2114
|
"resolveNode() needs the Node-direct renderer. Normal @nifrajs/node serving installs it automatically; direct callers should add `.use(nodeDirect())` (from `@nifrajs/core/node-direct`).",
|
|
1658
2115
|
)
|
|
1659
2116
|
}
|
|
1660
|
-
|
|
2117
|
+
const resolved = this.dispatch<NodeServeOutcome>(
|
|
1661
2118
|
source,
|
|
1662
2119
|
platform,
|
|
1663
2120
|
runtime.toOutcome,
|
|
@@ -1665,6 +2122,175 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1665
2122
|
runtime.timeout,
|
|
1666
2123
|
false,
|
|
1667
2124
|
)
|
|
2125
|
+
// Fold declared static headers into the record ONCE, here: before any native twin runs (so a
|
|
2126
|
+
// header or body hook reads them through its view), and on the no-hook path too (which returns
|
|
2127
|
+
// the outcome straight to the writer without the finish step below).
|
|
2128
|
+
const statics = this.staticResponseHeaders
|
|
2129
|
+
const outcome =
|
|
2130
|
+
statics === undefined
|
|
2131
|
+
? resolved
|
|
2132
|
+
: resolved instanceof Promise
|
|
2133
|
+
? resolved.then((settled) => withStaticNodeHeaders(settled, statics))
|
|
2134
|
+
: withStaticNodeHeaders(resolved, statics)
|
|
2135
|
+
if (webResponseHooks) {
|
|
2136
|
+
return outcome instanceof Promise
|
|
2137
|
+
? outcome.then((settled) => this.finishNodeWebResponse(settled, source, runtime))
|
|
2138
|
+
: this.finishNodeWebResponse(outcome, source, runtime)
|
|
2139
|
+
}
|
|
2140
|
+
if (!nativeResponseHooks) return outcome
|
|
2141
|
+
try {
|
|
2142
|
+
return outcome instanceof Promise
|
|
2143
|
+
? outcome.then((settled) => this.finishNodeResponse(settled, source, runtime))
|
|
2144
|
+
: this.finishNodeResponse(outcome, source, runtime)
|
|
2145
|
+
} catch (error) {
|
|
2146
|
+
// Keep resolveNode's failure shape promise-based, matching app.fetch and the adapter bridge.
|
|
2147
|
+
return Promise.reject(error)
|
|
2148
|
+
}
|
|
2149
|
+
}
|
|
2150
|
+
|
|
2151
|
+
/** Run generic Web response middleware while retaining direct writes for untouched buffered bodies. */
|
|
2152
|
+
private finishNodeWebResponse(
|
|
2153
|
+
outcome: NodeServeOutcome,
|
|
2154
|
+
source: RequestSource,
|
|
2155
|
+
runtime: NodeOutcomeRuntime,
|
|
2156
|
+
): MaybePromise<NodeServeOutcome> {
|
|
2157
|
+
const req = this.takeResponseRequest(source)
|
|
2158
|
+
const response = runtime.toResponse(outcome)
|
|
2159
|
+
const transformed = this.applyOnResponseAndFinalize(response, req)
|
|
2160
|
+
return transformed instanceof Promise
|
|
2161
|
+
? transformed.then(runtime.fromResponse)
|
|
2162
|
+
: runtime.fromResponse(transformed)
|
|
2163
|
+
}
|
|
2164
|
+
|
|
2165
|
+
/** True only when every transforming Web response hook has a header-only Node equivalent. */
|
|
2166
|
+
private canUseNodeResponseHooks(): boolean {
|
|
2167
|
+
return this.onResponseHooks.length > 0 && this.nodeResponseHooksComplete
|
|
2168
|
+
}
|
|
2169
|
+
|
|
2170
|
+
/** Apply paired native hooks to data outcomes; preserve the complete Web hook pipeline for Response outcomes. */
|
|
2171
|
+
private finishNodeResponse(
|
|
2172
|
+
outcome: NodeServeOutcome,
|
|
2173
|
+
source: RequestSource,
|
|
2174
|
+
runtime: NodeOutcomeRuntime,
|
|
2175
|
+
): MaybePromise<NodeServeOutcome> {
|
|
2176
|
+
if (outcome.kind === "response") {
|
|
2177
|
+
const req = this.takeResponseRequest(source)
|
|
2178
|
+
const transformed = this.applyOnResponseAndFinalize(outcome.response, req)
|
|
2179
|
+
return transformed instanceof Promise
|
|
2180
|
+
? transformed.then(runtime.fromResponse)
|
|
2181
|
+
: runtime.fromResponse(transformed)
|
|
2182
|
+
}
|
|
2183
|
+
|
|
2184
|
+
let headers = outcome.headers as Record<string, string | readonly string[]> | undefined
|
|
2185
|
+
if (outcome.kind === "json" && outcome.body !== null) {
|
|
2186
|
+
// The json render adds its Content-Type at WRITE time, so a body hook checking content types
|
|
2187
|
+
// would see nothing. Materialize the writer's own value into the hook-visible record - same
|
|
2188
|
+
// string the writer would emit, so the wire is unchanged.
|
|
2189
|
+
const hasType =
|
|
2190
|
+
headers !== undefined &&
|
|
2191
|
+
Object.keys(headers).some((key) => key.toLowerCase() === "content-type")
|
|
2192
|
+
if (!hasType) {
|
|
2193
|
+
const defaultContentType = runtime.jsonContentType ?? "application/json;charset=utf-8"
|
|
2194
|
+
if (headers === undefined) {
|
|
2195
|
+
headers = { "content-type": defaultContentType }
|
|
2196
|
+
} else {
|
|
2197
|
+
// The record belongs to this outcome. Add the implicit JSON type in place so the native
|
|
2198
|
+
// header walk does not clone every c.set.headers record before it can run. A hook that
|
|
2199
|
+
// replaces or deletes the type still takes the existing withNodeResponseHeaders path;
|
|
2200
|
+
// the writer's final defaulting behavior remains unchanged.
|
|
2201
|
+
headers["content-type"] = defaultContentType
|
|
2202
|
+
}
|
|
2203
|
+
}
|
|
2204
|
+
}
|
|
2205
|
+
const context: NodeResponseContext = {
|
|
2206
|
+
status: outcome.status,
|
|
2207
|
+
headers,
|
|
2208
|
+
cookies: outcome.kind === "json" ? outcome.cookies : undefined,
|
|
2209
|
+
body: outcome.body,
|
|
2210
|
+
}
|
|
2211
|
+
const applied = this.applyNodeResponseHooks(context, this.takeNodeResponseRequest(source))
|
|
2212
|
+
if (applied instanceof Promise) {
|
|
2213
|
+
return applied.then(() => this.withNodeResponseHeaders(outcome, context))
|
|
2214
|
+
}
|
|
2215
|
+
return this.withNodeResponseHeaders(outcome, context)
|
|
2216
|
+
}
|
|
2217
|
+
|
|
2218
|
+
private withNodeResponseHeaders(
|
|
2219
|
+
outcome: Exclude<NodeServeOutcome, { kind: "response" }>,
|
|
2220
|
+
context: NodeResponseContext,
|
|
2221
|
+
): NodeServeOutcome {
|
|
2222
|
+
const outcomeCookies = outcome.kind === "json" ? outcome.cookies : undefined
|
|
2223
|
+
const bodyChanged = context.body !== outcome.body
|
|
2224
|
+
const statusChanged = context.status !== outcome.status
|
|
2225
|
+
if (
|
|
2226
|
+
context.headers === outcome.headers &&
|
|
2227
|
+
context.cookies === outcomeCookies &&
|
|
2228
|
+
!bodyChanged &&
|
|
2229
|
+
!statusChanged
|
|
2230
|
+
) {
|
|
2231
|
+
return outcome
|
|
2232
|
+
}
|
|
2233
|
+
let headers = context.headers
|
|
2234
|
+
if (bodyChanged && headers !== undefined) {
|
|
2235
|
+
// A replaced body invalidates any explicitly carried length; the writers re-derive framing
|
|
2236
|
+
// from the final bytes.
|
|
2237
|
+
const stale = Object.keys(headers).find((key) => key.toLowerCase() === "content-length")
|
|
2238
|
+
if (stale !== undefined) {
|
|
2239
|
+
headers = { ...headers }
|
|
2240
|
+
delete headers[stale]
|
|
2241
|
+
}
|
|
2242
|
+
}
|
|
2243
|
+
if (outcome.kind === "json") {
|
|
2244
|
+
if (bodyChanged && context.body !== null && typeof context.body !== "string") {
|
|
2245
|
+
// A binary replacement can't ride the json render - switch to the buffered-body render,
|
|
2246
|
+
// folding queued cookies into explicit set-cookie lines so nothing is dropped.
|
|
2247
|
+
const record = Object.create(null) as Record<string, string | readonly string[]>
|
|
2248
|
+
if (headers !== undefined) Object.assign(record, headers)
|
|
2249
|
+
if (context.cookies !== undefined && context.cookies.length > 0) {
|
|
2250
|
+
record["set-cookie"] = [...context.cookies]
|
|
2251
|
+
}
|
|
2252
|
+
return { kind: "body", status: context.status, headers: record, body: context.body }
|
|
2253
|
+
}
|
|
2254
|
+
return {
|
|
2255
|
+
...outcome,
|
|
2256
|
+
status: context.status,
|
|
2257
|
+
headers,
|
|
2258
|
+
cookies: context.cookies,
|
|
2259
|
+
body: bodyChanged ? (context.body as string | null) : outcome.body,
|
|
2260
|
+
}
|
|
2261
|
+
}
|
|
2262
|
+
return {
|
|
2263
|
+
...outcome,
|
|
2264
|
+
status: context.status,
|
|
2265
|
+
headers,
|
|
2266
|
+
body: bodyChanged
|
|
2267
|
+
? ((context.body ?? new Uint8Array(0)) as string | Uint8Array)
|
|
2268
|
+
: outcome.body,
|
|
2269
|
+
}
|
|
2270
|
+
}
|
|
2271
|
+
|
|
2272
|
+
/** Synchronous until a native response hook actually returns a Promise. */
|
|
2273
|
+
private applyNodeResponseHooks(
|
|
2274
|
+
response: NodeResponseContext,
|
|
2275
|
+
req: NodeRequestContext,
|
|
2276
|
+
): MaybePromise<void> {
|
|
2277
|
+
for (let i = 0; i < this.onNodeResponseHooks.length; i++) {
|
|
2278
|
+
const hook = this.onNodeResponseHooks[i] as NodeResponseHook
|
|
2279
|
+
const result = hook(response, req)
|
|
2280
|
+
if (result instanceof Promise)
|
|
2281
|
+
return result.then(() => this.continueNodeResponseHooks(i + 1, response, req))
|
|
2282
|
+
}
|
|
2283
|
+
}
|
|
2284
|
+
|
|
2285
|
+
private async continueNodeResponseHooks(
|
|
2286
|
+
start: number,
|
|
2287
|
+
response: NodeResponseContext,
|
|
2288
|
+
req: NodeRequestContext,
|
|
2289
|
+
): Promise<void> {
|
|
2290
|
+
for (let i = start; i < this.onNodeResponseHooks.length; i++) {
|
|
2291
|
+
const hook = this.onNodeResponseHooks[i] as NodeResponseHook
|
|
2292
|
+
await hook(response, req)
|
|
2293
|
+
}
|
|
1668
2294
|
}
|
|
1669
2295
|
|
|
1670
2296
|
/**
|
|
@@ -1795,9 +2421,69 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1795
2421
|
if (this.onRequestHooks.length === 0) {
|
|
1796
2422
|
return this.routeAndRun(source, resolved, finalize, wrapResponse, onTimeout, webFast)
|
|
1797
2423
|
}
|
|
2424
|
+
if (!webFast && this.canUseNodeRequestHooks()) {
|
|
2425
|
+
return this.runWithNodeRequest(source, resolved, finalize, wrapResponse, onTimeout)
|
|
2426
|
+
}
|
|
1798
2427
|
return this.runWithOnRequest(source, resolved, finalize, wrapResponse, onTimeout, webFast)
|
|
1799
2428
|
}
|
|
1800
2429
|
|
|
2430
|
+
/** Node-native request hook walk. A header-only hook can inspect the lazy source without forcing a
|
|
2431
|
+
* Web `Request`; arbitrary request rewrites and full Web hooks use {@link runWithOnRequest}. */
|
|
2432
|
+
private runWithNodeRequest<T>(
|
|
2433
|
+
source: RequestSource,
|
|
2434
|
+
platform: Platform | undefined,
|
|
2435
|
+
finalize: (result: unknown, set: CtxSet) => T,
|
|
2436
|
+
wrapResponse: (response: Response) => T,
|
|
2437
|
+
onTimeout: () => T,
|
|
2438
|
+
): MaybePromise<T> {
|
|
2439
|
+
const hooks = this.onNodeRequestHooks
|
|
2440
|
+
const request = this.nodeRequestContextOf(source)
|
|
2441
|
+
for (let i = 0; i < hooks.length; i++) {
|
|
2442
|
+
const hook = hooks[i] as NodeRequestHook
|
|
2443
|
+
const outcome = hook(request, platform)
|
|
2444
|
+
if (outcome instanceof Promise) {
|
|
2445
|
+
return outcome.then((early) =>
|
|
2446
|
+
this.continueNodeRequest(
|
|
2447
|
+
early,
|
|
2448
|
+
i + 1,
|
|
2449
|
+
request,
|
|
2450
|
+
source,
|
|
2451
|
+
platform,
|
|
2452
|
+
finalize,
|
|
2453
|
+
wrapResponse,
|
|
2454
|
+
onTimeout,
|
|
2455
|
+
),
|
|
2456
|
+
)
|
|
2457
|
+
}
|
|
2458
|
+
if (outcome !== undefined) return wrapResponse(outcome)
|
|
2459
|
+
}
|
|
2460
|
+
return this.routeAndRun(source, platform, finalize, wrapResponse, onTimeout, false)
|
|
2461
|
+
}
|
|
2462
|
+
|
|
2463
|
+
private async continueNodeRequest<T>(
|
|
2464
|
+
first: Response | undefined,
|
|
2465
|
+
nextIndex: number,
|
|
2466
|
+
request: NodeRequestContext,
|
|
2467
|
+
source: RequestSource,
|
|
2468
|
+
platform: Platform | undefined,
|
|
2469
|
+
finalize: (result: unknown, set: CtxSet) => T,
|
|
2470
|
+
wrapResponse: (response: Response) => T,
|
|
2471
|
+
onTimeout: () => T,
|
|
2472
|
+
): Promise<T> {
|
|
2473
|
+
if (first !== undefined) return wrapResponse(first)
|
|
2474
|
+
for (let i = nextIndex; i < this.onNodeRequestHooks.length; i++) {
|
|
2475
|
+
const hook = this.onNodeRequestHooks[i] as NodeRequestHook
|
|
2476
|
+
const outcome = hook(request, platform)
|
|
2477
|
+
const early = outcome instanceof Promise ? await outcome : outcome
|
|
2478
|
+
if (early !== undefined) return wrapResponse(early)
|
|
2479
|
+
}
|
|
2480
|
+
return this.routeAndRun(source, platform, finalize, wrapResponse, onTimeout, false)
|
|
2481
|
+
}
|
|
2482
|
+
|
|
2483
|
+
private canUseNodeRequestHooks(): boolean {
|
|
2484
|
+
return this.onRequestHooks.length > 0 && this.nodeRequestHooksComplete
|
|
2485
|
+
}
|
|
2486
|
+
|
|
1801
2487
|
/**
|
|
1802
2488
|
* onRequest short-circuit path. Synchronous as long as every hook returns synchronously (the
|
|
1803
2489
|
* common case - e.g. CORS returning `undefined` for a non-preflight request): an `async` version
|
|
@@ -1815,6 +2501,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1815
2501
|
): MaybePromise<T> {
|
|
1816
2502
|
const hooks = this.onRequestHooks
|
|
1817
2503
|
const originalRequest = requestOf(source)
|
|
2504
|
+
this.responseSources.set(source as object, originalRequest)
|
|
1818
2505
|
let current: RequestSource = source
|
|
1819
2506
|
for (let i = 0; i < hooks.length; i++) {
|
|
1820
2507
|
const outcome = (hooks[i] as RawOnRequest)(requestOf(current), platform)
|
|
@@ -1875,6 +2562,16 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1875
2562
|
}
|
|
1876
2563
|
|
|
1877
2564
|
private takeResponseRequest(source: RequestSource): Request {
|
|
2565
|
+
const tracked = this.responseSources.get(source as object)
|
|
2566
|
+
if (tracked !== undefined) {
|
|
2567
|
+
this.responseSources.delete(source as object)
|
|
2568
|
+
const rewritten = this.responseRequests.get(tracked)
|
|
2569
|
+
if (rewritten !== undefined) {
|
|
2570
|
+
this.responseRequests.delete(tracked)
|
|
2571
|
+
return rewritten
|
|
2572
|
+
}
|
|
2573
|
+
return tracked
|
|
2574
|
+
}
|
|
1878
2575
|
const request = requestOf(source)
|
|
1879
2576
|
const rewritten = this.responseRequests.get(request)
|
|
1880
2577
|
if (rewritten === undefined) return request
|
|
@@ -1882,6 +2579,51 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1882
2579
|
return rewritten
|
|
1883
2580
|
}
|
|
1884
2581
|
|
|
2582
|
+
/**
|
|
2583
|
+
* The NodeRequestContext for a source, MEMOIZED per source so the request twins and the response
|
|
2584
|
+
* twins receive the exact same object within one request - that identity is the documented
|
|
2585
|
+
* contract stateful twins key their WeakMaps on. An adapter source (which already speaks the
|
|
2586
|
+
* interface) is returned as-is; a plain `Request` source (a direct `resolveNode` caller) gets one
|
|
2587
|
+
* cached wrapper.
|
|
2588
|
+
*/
|
|
2589
|
+
private nodeRequestContextOf(source: RequestSource): NodeRequestContext {
|
|
2590
|
+
if (source.header !== undefined) return source as unknown as NodeRequestContext
|
|
2591
|
+
let context = this.nodeContexts.get(source as object)
|
|
2592
|
+
if (context === undefined) {
|
|
2593
|
+
const request = requestOf(source)
|
|
2594
|
+
context = {
|
|
2595
|
+
method: request.method,
|
|
2596
|
+
url: request.url,
|
|
2597
|
+
header: (name) => request.headers.get(name),
|
|
2598
|
+
}
|
|
2599
|
+
this.nodeContexts.set(source as object, context)
|
|
2600
|
+
}
|
|
2601
|
+
return context
|
|
2602
|
+
}
|
|
2603
|
+
|
|
2604
|
+
/** Preserve the request visible to generic onRequest hooks for paired native response hooks. */
|
|
2605
|
+
private takeNodeResponseRequest(source: RequestSource): NodeRequestContext {
|
|
2606
|
+
const tracked = this.responseSources.get(source as object)
|
|
2607
|
+
if (tracked !== undefined) {
|
|
2608
|
+
this.responseSources.delete(source as object)
|
|
2609
|
+
const rewritten = this.responseRequests.get(tracked)
|
|
2610
|
+
if (rewritten !== undefined) {
|
|
2611
|
+
this.responseRequests.delete(tracked)
|
|
2612
|
+
return {
|
|
2613
|
+
method: rewritten.method,
|
|
2614
|
+
url: rewritten.url,
|
|
2615
|
+
header: (name) => rewritten.headers.get(name),
|
|
2616
|
+
}
|
|
2617
|
+
}
|
|
2618
|
+
return {
|
|
2619
|
+
method: tracked.method,
|
|
2620
|
+
url: tracked.url,
|
|
2621
|
+
header: (name) => tracked.headers.get(name),
|
|
2622
|
+
}
|
|
2623
|
+
}
|
|
2624
|
+
return this.nodeRequestContextOf(source)
|
|
2625
|
+
}
|
|
2626
|
+
|
|
1885
2627
|
/**
|
|
1886
2628
|
* Route → build context → run. Synchronous through to the handler for a **bare** route
|
|
1887
2629
|
* (selected by its compiled plan), so a sync handler produces its result with zero promise allocations;
|
|
@@ -1895,8 +2637,45 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1895
2637
|
onTimeout: () => T,
|
|
1896
2638
|
webFast: boolean,
|
|
1897
2639
|
): MaybePromise<T> {
|
|
1898
|
-
|
|
1899
|
-
|
|
2640
|
+
// Routing only needs the pathname. Scan the URL once but keep the two slices in locals instead
|
|
2641
|
+
// of allocating the `{ pathname, search }` pair returned by the public helper on every request.
|
|
2642
|
+
let pathname: string
|
|
2643
|
+
let search: string
|
|
2644
|
+
// Read the source's split ONCE. `urlParts` is an accessor on the Node request sources that
|
|
2645
|
+
// rescans the target and allocates a fresh pair every time it is touched, so testing it and then
|
|
2646
|
+
// reading each half through the accessor scanned the URL three times per request and threw two
|
|
2647
|
+
// of the three pairs away immediately.
|
|
2648
|
+
const parts = source.urlParts
|
|
2649
|
+
if (parts !== undefined) {
|
|
2650
|
+
pathname = parts.pathname
|
|
2651
|
+
search = parts.search
|
|
2652
|
+
} else {
|
|
2653
|
+
const rawUrl = source.url
|
|
2654
|
+
const schemeEnd = rawUrl.indexOf("://")
|
|
2655
|
+
const start = schemeEnd === -1 ? rawUrl.indexOf("/") : rawUrl.indexOf("/", schemeEnd + 3)
|
|
2656
|
+
if (start === -1) {
|
|
2657
|
+
pathname = "/"
|
|
2658
|
+
search = ""
|
|
2659
|
+
} else {
|
|
2660
|
+
let pathEnd = rawUrl.length
|
|
2661
|
+
let searchStart = -1
|
|
2662
|
+
let searchEnd = rawUrl.length
|
|
2663
|
+
for (let i = start; i < rawUrl.length; i++) {
|
|
2664
|
+
const c = rawUrl.charCodeAt(i)
|
|
2665
|
+
if (c === 63 /* ? */ && searchStart === -1) {
|
|
2666
|
+
pathEnd = i
|
|
2667
|
+
searchStart = i
|
|
2668
|
+
} else if (c === 35 /* # */) {
|
|
2669
|
+
if (searchStart === -1) pathEnd = i
|
|
2670
|
+
searchEnd = i
|
|
2671
|
+
break
|
|
2672
|
+
}
|
|
2673
|
+
}
|
|
2674
|
+
pathname = rawUrl.slice(start, pathEnd)
|
|
2675
|
+
search = searchStart === -1 ? "" : rawUrl.slice(searchStart, searchEnd)
|
|
2676
|
+
}
|
|
2677
|
+
}
|
|
2678
|
+
const match = this.catalog.find(source.method, pathname)
|
|
1900
2679
|
if (!match.found) {
|
|
1901
2680
|
if (match.reason === "method-not-allowed") {
|
|
1902
2681
|
return wrapResponse(
|
|
@@ -1918,7 +2697,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1918
2697
|
platform,
|
|
1919
2698
|
match.payload,
|
|
1920
2699
|
params,
|
|
1921
|
-
|
|
2700
|
+
search,
|
|
1922
2701
|
finalize,
|
|
1923
2702
|
wrapResponse,
|
|
1924
2703
|
onTimeout,
|
|
@@ -2017,6 +2796,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2017
2796
|
? getUnboundedRequestBudget()
|
|
2018
2797
|
: createRequestBudget({ deadline: admission!.deadline as number, signal })
|
|
2019
2798
|
const plan = entry.execution
|
|
2799
|
+
const nativeContext = controller === undefined
|
|
2020
2800
|
const outcome: MaybePromise<T> =
|
|
2021
2801
|
webFast && plan.fusedWeb !== undefined
|
|
2022
2802
|
? (plan.fusedWeb(
|
|
@@ -2026,20 +2806,33 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2026
2806
|
signal,
|
|
2027
2807
|
budget,
|
|
2028
2808
|
platform,
|
|
2029
|
-
|
|
2809
|
+
nativeContext,
|
|
2030
2810
|
) as MaybePromise<T>)
|
|
2031
|
-
: plan.
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2811
|
+
: !webFast && plan.fusedBody !== undefined
|
|
2812
|
+
? plan.fusedBody(
|
|
2813
|
+
source,
|
|
2814
|
+
params,
|
|
2815
|
+
search,
|
|
2816
|
+
signal,
|
|
2817
|
+
budget,
|
|
2818
|
+
platform,
|
|
2819
|
+
false,
|
|
2820
|
+
finalize,
|
|
2821
|
+
wrapResponse,
|
|
2822
|
+
)
|
|
2823
|
+
: plan.run(
|
|
2824
|
+
this,
|
|
2825
|
+
entry,
|
|
2826
|
+
source,
|
|
2827
|
+
params,
|
|
2828
|
+
search,
|
|
2829
|
+
signal,
|
|
2830
|
+
budget,
|
|
2831
|
+
platform,
|
|
2832
|
+
nativeContext,
|
|
2833
|
+
finalize,
|
|
2834
|
+
wrapResponse,
|
|
2835
|
+
)
|
|
2043
2836
|
// The request timeout only bounds work that is actually pending - a synchronous (bare) result is
|
|
2044
2837
|
// already complete and can't time out, so it's returned as-is (no 503 race, no promise).
|
|
2045
2838
|
if (controller !== undefined && outcome instanceof Promise) {
|
|
@@ -2179,10 +2972,12 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2179
2972
|
decorations: Record<PropertyKey, unknown> | undefined,
|
|
2180
2973
|
contextless: boolean,
|
|
2181
2974
|
): FusedWebRunner {
|
|
2975
|
+
// The fused lanes return to the runtime directly, so the framework's own error renders fold in
|
|
2976
|
+
// static headers here rather than through the shared `wrapResponse` seam.
|
|
2182
2977
|
const logError = (err: unknown, ctx: RawContext): Response => {
|
|
2183
|
-
if (err instanceof Response) return err
|
|
2978
|
+
if (err instanceof Response) return this.wrapWebResponse(err)
|
|
2184
2979
|
this.logRequestError(err, ctx)
|
|
2185
|
-
return jsonError(500, "internal_error")
|
|
2980
|
+
return this.wrapWebResponse(jsonError(500, "internal_error"))
|
|
2186
2981
|
}
|
|
2187
2982
|
if (contextless && decorations === undefined) {
|
|
2188
2983
|
// `() => ...` can't observe the context - skip allocating one entirely (errors still build
|
|
@@ -2196,7 +2991,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2196
2991
|
return logError(
|
|
2197
2992
|
err,
|
|
2198
2993
|
nativeContext
|
|
2199
|
-
? RequestContext.native(source, params, this.maxBodyBytes)
|
|
2994
|
+
? RequestContext.native(source, params, search, this.maxBodyBytes, platform)
|
|
2200
2995
|
: new RequestContext(
|
|
2201
2996
|
source,
|
|
2202
2997
|
params,
|
|
@@ -2209,29 +3004,31 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2209
3004
|
)
|
|
2210
3005
|
}
|
|
2211
3006
|
if (result instanceof Promise) {
|
|
2212
|
-
return result.then(
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
|
|
2216
|
-
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
|
|
2221
|
-
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
|
-
|
|
2225
|
-
|
|
2226
|
-
|
|
3007
|
+
return result.then(
|
|
3008
|
+
(value) => fusedRespondNoSet(value, this.responseBodyTag, this.staticResponseHeaders),
|
|
3009
|
+
(err) =>
|
|
3010
|
+
logError(
|
|
3011
|
+
err,
|
|
3012
|
+
nativeContext
|
|
3013
|
+
? RequestContext.native(source, params, search, this.maxBodyBytes, platform)
|
|
3014
|
+
: new RequestContext(
|
|
3015
|
+
source,
|
|
3016
|
+
params,
|
|
3017
|
+
search,
|
|
3018
|
+
signal,
|
|
3019
|
+
budget,
|
|
3020
|
+
platform,
|
|
3021
|
+
this.maxBodyBytes,
|
|
3022
|
+
),
|
|
3023
|
+
),
|
|
2227
3024
|
)
|
|
2228
3025
|
}
|
|
2229
|
-
return fusedRespondNoSet(result)
|
|
3026
|
+
return fusedRespondNoSet(result, this.responseBodyTag, this.staticResponseHeaders)
|
|
2230
3027
|
}
|
|
2231
3028
|
}
|
|
2232
3029
|
return (source, params, search, signal, budget, platform, nativeContext) => {
|
|
2233
3030
|
const ctx = nativeContext
|
|
2234
|
-
? RequestContext.native(source, params, this.maxBodyBytes)
|
|
3031
|
+
? RequestContext.native(source, params, search, this.maxBodyBytes, platform)
|
|
2235
3032
|
: new RequestContext(source, params, search, signal, budget, platform, this.maxBodyBytes)
|
|
2236
3033
|
if (decorations !== undefined) Object.assign(ctx, decorations)
|
|
2237
3034
|
let result: unknown
|
|
@@ -2242,33 +3039,162 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2242
3039
|
}
|
|
2243
3040
|
if (result instanceof Promise) {
|
|
2244
3041
|
return result.then(
|
|
2245
|
-
(value) => fusedRespond(value, ctx),
|
|
3042
|
+
(value) => fusedRespond(value, ctx, this.responseBodyTag, this.staticResponseHeaders),
|
|
2246
3043
|
(err) => logError(err, ctx),
|
|
2247
3044
|
)
|
|
2248
3045
|
}
|
|
2249
|
-
return fusedRespond(result, ctx)
|
|
3046
|
+
return fusedRespond(result, ctx, this.responseBodyTag, this.staticResponseHeaders)
|
|
2250
3047
|
}
|
|
2251
3048
|
}
|
|
2252
3049
|
|
|
2253
|
-
/**
|
|
2254
|
-
*
|
|
2255
|
-
*
|
|
2256
|
-
|
|
2257
|
-
* `validationError(issues)` (recovery hooks disqualify the route from this lane), a thrown
|
|
2258
|
-
* `Response` passes through, anything else logs and returns a flat 500, and an async validator
|
|
2259
|
-
* falls to a then-chain with the same steps. */
|
|
2260
|
-
private buildFusedQueryWeb(
|
|
3050
|
+
/** Compile the eligible body-only route's parser → validator → handler continuation once. The
|
|
3051
|
+
* bounded parser remains shared with the generic lane; only the route-invariant entry lookups and
|
|
3052
|
+
* lifecycle dispatch disappear from the common synchronous-validator/synchronous-handler case. */
|
|
3053
|
+
private buildFusedBodyRunner(
|
|
2261
3054
|
handler: InternalHandler,
|
|
3055
|
+
bodySchema: StandardSchemaV1,
|
|
2262
3056
|
decorations: Record<PropertyKey, unknown> | undefined,
|
|
2263
|
-
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
const runHandler =
|
|
2271
|
-
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
|
|
2272
3198
|
let result: unknown
|
|
2273
3199
|
try {
|
|
2274
3200
|
result = handler(ctx)
|
|
@@ -2277,15 +3203,15 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2277
3203
|
}
|
|
2278
3204
|
if (result instanceof Promise) {
|
|
2279
3205
|
return result.then(
|
|
2280
|
-
(settled) => fusedRespond(settled, ctx),
|
|
3206
|
+
(settled) => fusedRespond(settled, ctx, this.responseBodyTag, this.staticResponseHeaders),
|
|
2281
3207
|
(err) => logError(err, ctx),
|
|
2282
3208
|
)
|
|
2283
3209
|
}
|
|
2284
|
-
return fusedRespond(result, ctx)
|
|
3210
|
+
return fusedRespond(result, ctx, this.responseBodyTag, this.staticResponseHeaders)
|
|
2285
3211
|
}
|
|
2286
3212
|
return (source, params, search, signal, budget, platform, nativeContext) => {
|
|
2287
3213
|
const ctx = nativeContext
|
|
2288
|
-
? RequestContext.native(source, params, this.maxBodyBytes)
|
|
3214
|
+
? RequestContext.native(source, params, search, this.maxBodyBytes, platform)
|
|
2289
3215
|
: new RequestContext(source, params, search, signal, budget, platform, this.maxBodyBytes)
|
|
2290
3216
|
if (decorations !== undefined) Object.assign(ctx, decorations)
|
|
2291
3217
|
let validation: MaybePromise<StandardResult<unknown>>
|
|
@@ -2298,12 +3224,13 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2298
3224
|
return validation.then(
|
|
2299
3225
|
(settled) =>
|
|
2300
3226
|
settled.issues !== undefined
|
|
2301
|
-
? validationError(settled.issues)
|
|
3227
|
+
? this.wrapWebResponse(validationError(settled.issues))
|
|
2302
3228
|
: runHandler(ctx, settled.value),
|
|
2303
3229
|
(err) => logError(err, ctx),
|
|
2304
3230
|
)
|
|
2305
3231
|
}
|
|
2306
|
-
if (validation.issues !== undefined)
|
|
3232
|
+
if (validation.issues !== undefined)
|
|
3233
|
+
return this.wrapWebResponse(validationError(validation.issues))
|
|
2307
3234
|
return runHandler(ctx, validation.value)
|
|
2308
3235
|
}
|
|
2309
3236
|
}
|
|
@@ -2356,26 +3283,36 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2356
3283
|
ctx: RawContext,
|
|
2357
3284
|
finalize: (result: unknown, set: CtxSet) => T,
|
|
2358
3285
|
wrapResponse: (response: Response) => T,
|
|
3286
|
+
): Promise<T> {
|
|
3287
|
+
return this.readBodyInput(
|
|
3288
|
+
source,
|
|
3289
|
+
(parsed) => this.finishBodyOnly(entry, parsed, ctx, finalize, wrapResponse),
|
|
3290
|
+
wrapResponse,
|
|
3291
|
+
(err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse),
|
|
3292
|
+
)
|
|
3293
|
+
}
|
|
3294
|
+
|
|
3295
|
+
/** Shared bounded body framing/parser. Both body lanes use the same trust-boundary checks; only
|
|
3296
|
+
* the validation + handler continuation differs. */
|
|
3297
|
+
private readBodyInput<T>(
|
|
3298
|
+
source: RequestSource,
|
|
3299
|
+
onParsed: (parsed: unknown) => MaybePromise<T>,
|
|
3300
|
+
wrapResponse: (response: Response) => T,
|
|
3301
|
+
onError: (err: unknown) => MaybePromise<T>,
|
|
2359
3302
|
): Promise<T> {
|
|
2360
3303
|
const contentType = headerOf(source, "content-type") ?? ""
|
|
2361
3304
|
if (contentType !== "application/json" && !contentType.includes("application/json")) {
|
|
2362
3305
|
if (isUrlEncodedForm(contentType)) {
|
|
2363
|
-
|
|
2364
|
-
|
|
2365
|
-
|
|
2366
|
-
|
|
2367
|
-
if (form instanceof Response) return wrapResponse(form)
|
|
2368
|
-
return this.finishBodyOnly(entry, form, ctx, finalize, wrapResponse) as Promise<T> | T
|
|
2369
|
-
}) as Promise<T>
|
|
3306
|
+
return readBoundedForm(source, this.maxBodyBytes).then(
|
|
3307
|
+
(form) => (form instanceof Response ? wrapResponse(form) : onParsed(form)),
|
|
3308
|
+
onError,
|
|
3309
|
+
) as Promise<T>
|
|
2370
3310
|
}
|
|
2371
3311
|
return Promise.resolve(wrapResponse(jsonError(415, "unsupported_media_type")))
|
|
2372
3312
|
}
|
|
2373
3313
|
|
|
2374
|
-
//
|
|
2375
|
-
//
|
|
2376
|
-
// `readBoundedJson` (an extra async-fn frame + per-request `finish`/`applyValidation`
|
|
2377
|
-
// closures) is kept for the chunked / length-less / oversized cases it exists for.
|
|
2378
|
-
// Semantics are identical to readBoundedJsonSource - same checks, same error codes.
|
|
3314
|
+
// A framed, in-cap, non-chunked body can use native json() directly. Chunked or length-less
|
|
3315
|
+
// bodies use readBoundedJson, which enforces the streaming cap before parsing.
|
|
2379
3316
|
const declared = headerOf(source, "content-length")
|
|
2380
3317
|
if (declared !== null) {
|
|
2381
3318
|
const length = parseContentLength(declared)
|
|
@@ -2386,25 +3323,17 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2386
3323
|
return Promise.resolve(wrapResponse(jsonError(413, "payload_too_large")))
|
|
2387
3324
|
}
|
|
2388
3325
|
if (headerOf(source, "transfer-encoding") === null) {
|
|
2389
|
-
return source.json().then(
|
|
2390
|
-
(parsed) => this.finishBodyOnly(entry, parsed, ctx, finalize, wrapResponse),
|
|
2391
|
-
// Native json() rejection = malformed JSON; anything past parse goes through
|
|
2392
|
-
// finishBodyOnly's own error handling.
|
|
2393
|
-
() => wrapResponse(jsonError(400, "invalid_json")),
|
|
2394
|
-
)
|
|
3326
|
+
return source.json().then(onParsed, () => wrapResponse(jsonError(400, "invalid_json")))
|
|
2395
3327
|
}
|
|
2396
3328
|
}
|
|
2397
3329
|
|
|
2398
3330
|
try {
|
|
2399
3331
|
return this.readBoundedJson(source).then(
|
|
2400
|
-
(parsed) =>
|
|
2401
|
-
|
|
2402
|
-
return this.finishBodyOnly(entry, parsed, ctx, finalize, wrapResponse)
|
|
2403
|
-
},
|
|
2404
|
-
(err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse),
|
|
3332
|
+
(parsed) => (parsed instanceof Response ? wrapResponse(parsed) : onParsed(parsed)),
|
|
3333
|
+
onError,
|
|
2405
3334
|
)
|
|
2406
3335
|
} catch (err) {
|
|
2407
|
-
return
|
|
3336
|
+
return Promise.resolve(onError(err))
|
|
2408
3337
|
}
|
|
2409
3338
|
}
|
|
2410
3339
|
|
|
@@ -2678,14 +3607,18 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2678
3607
|
response: Response,
|
|
2679
3608
|
error: unknown,
|
|
2680
3609
|
req: Request,
|
|
2681
|
-
): Promise<never>
|
|
3610
|
+
): Promise<never> {
|
|
2682
3611
|
const notified = this.notifyResponseFinalized({ response, error }, req)
|
|
2683
3612
|
if (notified instanceof Promise) {
|
|
2684
3613
|
return notified.then(() => {
|
|
2685
3614
|
throw error
|
|
2686
3615
|
})
|
|
2687
3616
|
}
|
|
2688
|
-
|
|
3617
|
+
// A hook failure must surface as a REJECTION even when every prior step ran synchronously:
|
|
3618
|
+
// `fetch()` may now resolve without a promise, but its failure contract stays promise-shaped -
|
|
3619
|
+
// a synchronous throw here would escape `Promise.resolve(app.fetch(...))` bridges and
|
|
3620
|
+
// `.then()`-style callers entirely instead of reaching their rejection handling.
|
|
3621
|
+
return Promise.reject(error)
|
|
2689
3622
|
}
|
|
2690
3623
|
|
|
2691
3624
|
/** Notify terminal observers in order while isolating both sync and async failures. */
|
|
@@ -2756,109 +3689,532 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2756
3689
|
* Generic over the render: a success value goes through `finalize(result, set)` (a Web `Response`, or
|
|
2757
3690
|
* node-direct primitives); an early/error `Response` (validation, thrown, 500) through `wrapResponse`.
|
|
2758
3691
|
*/
|
|
2759
|
-
|
|
3692
|
+
/** Synchronous until a validator, lifecycle hook, handler, or contract check actually returns a Promise. */
|
|
3693
|
+
private runLifecycle<T>(
|
|
2760
3694
|
entry: RouteEntry,
|
|
2761
3695
|
source: RequestSource,
|
|
2762
3696
|
ctx: RawContext,
|
|
2763
3697
|
finalize: (result: unknown, set: CtxSet) => T,
|
|
2764
3698
|
wrapResponse: (response: Response) => T,
|
|
2765
|
-
):
|
|
3699
|
+
): MaybePromise<T> {
|
|
2766
3700
|
try {
|
|
2767
|
-
|
|
2768
|
-
|
|
2769
|
-
|
|
2770
|
-
|
|
2771
|
-
|
|
2772
|
-
|
|
2773
|
-
|
|
2774
|
-
|
|
2775
|
-
|
|
2776
|
-
|
|
2777
|
-
|
|
2778
|
-
|
|
2779
|
-
|
|
2780
|
-
|
|
2781
|
-
|
|
2782
|
-
|
|
2783
|
-
// validateStandard wrapper alloc.
|
|
2784
|
-
const validation = entry.schema.query["~standard"].validate(
|
|
2785
|
-
queryObjectOf(ctx[CONTEXT_SEARCH]),
|
|
2786
|
-
)
|
|
2787
|
-
const result = validation instanceof Promise ? await validation : validation
|
|
2788
|
-
const queryError = await this.applyLifecycleValidation(entry, result, ctx, "query")
|
|
2789
|
-
if (queryError !== undefined) return wrapResponse(queryError)
|
|
2790
|
-
}
|
|
2791
|
-
|
|
2792
|
-
// Context extensions: static decorations, then per-request derives.
|
|
2793
|
-
// Each hook below only awaits when it actually returns a Promise - a sync hook
|
|
2794
|
-
// skips the microtask tick (a fast-path; ~64 ns/hook).
|
|
2795
|
-
if (entry.hasDecorations) Object.assign(ctx, entry.decorations) // skip the no-op on bare routes
|
|
2796
|
-
// The `.length` guards skip iterator setup for the common no-hook route (most
|
|
2797
|
-
// routes have neither derives nor before/after hooks); a hooked route pays only
|
|
2798
|
-
// the length check.
|
|
2799
|
-
if (entry.derives.length > 0) {
|
|
2800
|
-
for (const derive of entry.derives) {
|
|
2801
|
-
const extension = derive(ctx)
|
|
2802
|
-
Object.assign(ctx, extension instanceof Promise ? await extension : extension)
|
|
3701
|
+
const paramsSchema = entry.schema?.params
|
|
3702
|
+
if (paramsSchema !== undefined) {
|
|
3703
|
+
const validation = paramsSchema["~standard"].validate(ctx.params)
|
|
3704
|
+
if (validation instanceof Promise) {
|
|
3705
|
+
return validation.then(
|
|
3706
|
+
(result) =>
|
|
3707
|
+
this.runLifecycleAfterParamsResult(
|
|
3708
|
+
entry,
|
|
3709
|
+
source,
|
|
3710
|
+
ctx,
|
|
3711
|
+
finalize,
|
|
3712
|
+
wrapResponse,
|
|
3713
|
+
result,
|
|
3714
|
+
),
|
|
3715
|
+
(err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse),
|
|
3716
|
+
)
|
|
2803
3717
|
}
|
|
3718
|
+
return this.runLifecycleAfterParamsResult(
|
|
3719
|
+
entry,
|
|
3720
|
+
source,
|
|
3721
|
+
ctx,
|
|
3722
|
+
finalize,
|
|
3723
|
+
wrapResponse,
|
|
3724
|
+
validation,
|
|
3725
|
+
)
|
|
3726
|
+
}
|
|
3727
|
+
// Registration already knows whether this route has body/params/query validation. Most real
|
|
3728
|
+
// read routes have no body or params schema, so skip the generic stage ladder and go straight to
|
|
3729
|
+
// query validation + lifecycle hooks. The fallback below remains the complete path for routes
|
|
3730
|
+
// with body/params combinations and validation recovery.
|
|
3731
|
+
if (entry.schema?.body === undefined) {
|
|
3732
|
+
return this.runQueryAndLifecycle(entry, ctx, finalize, wrapResponse)
|
|
2804
3733
|
}
|
|
3734
|
+
return this.runLifecycleAfterParams(entry, source, ctx, finalize, wrapResponse, undefined)
|
|
3735
|
+
} catch (err) {
|
|
3736
|
+
return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse)
|
|
3737
|
+
}
|
|
3738
|
+
}
|
|
2805
3739
|
|
|
2806
|
-
|
|
2807
|
-
|
|
2808
|
-
|
|
2809
|
-
|
|
2810
|
-
|
|
2811
|
-
|
|
2812
|
-
|
|
3740
|
+
/** Registration-specialized query lifecycle: query validation (including recovery) → hooks. */
|
|
3741
|
+
private runLifecycleQuery<T>(
|
|
3742
|
+
entry: RouteEntry,
|
|
3743
|
+
ctx: RawContext,
|
|
3744
|
+
finalize: (result: unknown, set: CtxSet) => T,
|
|
3745
|
+
wrapResponse: (response: Response) => T,
|
|
3746
|
+
): MaybePromise<T> {
|
|
3747
|
+
try {
|
|
3748
|
+
// The execution plan only selects this runner when the route has a query schema. Keeping the
|
|
3749
|
+
// non-null access here removes the per-request schema-presence branch from realistic GETs.
|
|
3750
|
+
const validation = entry.schema!.query!["~standard"].validate(
|
|
3751
|
+
queryObjectOf(ctx[CONTEXT_SEARCH]),
|
|
3752
|
+
)
|
|
3753
|
+
if (validation instanceof Promise) {
|
|
3754
|
+
return validation.then(
|
|
3755
|
+
(result) => {
|
|
3756
|
+
try {
|
|
3757
|
+
return this.runQueryAndLifecycleResult(entry, ctx, finalize, wrapResponse, result)
|
|
3758
|
+
} catch (err) {
|
|
3759
|
+
return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse)
|
|
3760
|
+
}
|
|
3761
|
+
},
|
|
3762
|
+
(err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse),
|
|
3763
|
+
)
|
|
2813
3764
|
}
|
|
3765
|
+
return this.runQueryAndLifecycleResult(entry, ctx, finalize, wrapResponse, validation)
|
|
3766
|
+
} catch (err) {
|
|
3767
|
+
return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse)
|
|
3768
|
+
}
|
|
3769
|
+
}
|
|
2814
3770
|
|
|
2815
|
-
|
|
2816
|
-
|
|
3771
|
+
/** Registration-specialized body lifecycle: bounded body validation (including recovery) → hooks. */
|
|
3772
|
+
private runLifecycleBody<T>(
|
|
3773
|
+
entry: RouteEntry,
|
|
3774
|
+
source: RequestSource,
|
|
3775
|
+
ctx: RawContext,
|
|
3776
|
+
finalize: (result: unknown, set: CtxSet) => T,
|
|
3777
|
+
wrapResponse: (response: Response) => T,
|
|
3778
|
+
): MaybePromise<T> {
|
|
3779
|
+
return this.readAndValidateBody(source, entry, ctx).then(
|
|
3780
|
+
(bodyError) =>
|
|
3781
|
+
bodyError === undefined
|
|
3782
|
+
? this.runLifecycleHooks(entry, ctx, finalize, wrapResponse)
|
|
3783
|
+
: wrapResponse(bodyError),
|
|
3784
|
+
(err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse),
|
|
3785
|
+
)
|
|
3786
|
+
}
|
|
2817
3787
|
|
|
2818
|
-
|
|
2819
|
-
|
|
2820
|
-
|
|
2821
|
-
|
|
2822
|
-
|
|
2823
|
-
|
|
3788
|
+
/** Registration-specialized body + query lifecycle: bounded body validation → query validation → hooks. */
|
|
3789
|
+
private runLifecycleBodyQuery<T>(
|
|
3790
|
+
entry: RouteEntry,
|
|
3791
|
+
source: RequestSource,
|
|
3792
|
+
ctx: RawContext,
|
|
3793
|
+
finalize: (result: unknown, set: CtxSet) => T,
|
|
3794
|
+
wrapResponse: (response: Response) => T,
|
|
3795
|
+
): MaybePromise<T> {
|
|
3796
|
+
return this.readAndValidateBody(source, entry, ctx).then(
|
|
3797
|
+
(bodyError) =>
|
|
3798
|
+
bodyError === undefined
|
|
3799
|
+
? this.runLifecycleQuery(entry, ctx, finalize, wrapResponse)
|
|
3800
|
+
: wrapResponse(bodyError),
|
|
3801
|
+
(err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse),
|
|
3802
|
+
)
|
|
3803
|
+
}
|
|
3804
|
+
|
|
3805
|
+
/** Registration-specialized no-body lifecycle: query validation (if present) → derives/hooks. */
|
|
3806
|
+
private runQueryAndLifecycle<T>(
|
|
3807
|
+
entry: RouteEntry,
|
|
3808
|
+
ctx: RawContext,
|
|
3809
|
+
finalize: (result: unknown, set: CtxSet) => T,
|
|
3810
|
+
wrapResponse: (response: Response) => T,
|
|
3811
|
+
): MaybePromise<T> {
|
|
3812
|
+
const querySchema = entry.schema?.query
|
|
3813
|
+
return querySchema === undefined
|
|
3814
|
+
? this.runLifecycleHooks(entry, ctx, finalize, wrapResponse)
|
|
3815
|
+
: this.runLifecycleQuery(entry, ctx, finalize, wrapResponse)
|
|
3816
|
+
}
|
|
3817
|
+
|
|
3818
|
+
private runQueryAndLifecycleResult<T>(
|
|
3819
|
+
entry: RouteEntry,
|
|
3820
|
+
ctx: RawContext,
|
|
3821
|
+
finalize: (result: unknown, set: CtxSet) => T,
|
|
3822
|
+
wrapResponse: (response: Response) => T,
|
|
3823
|
+
result: StandardResult<unknown>,
|
|
3824
|
+
): MaybePromise<T> {
|
|
3825
|
+
const queryError = this.applyLifecycleValidation(entry, result, ctx, "query")
|
|
3826
|
+
if (queryError instanceof Promise) {
|
|
3827
|
+
return queryError.then(
|
|
3828
|
+
(error) =>
|
|
3829
|
+
error === undefined
|
|
3830
|
+
? this.runLifecycleHooks(entry, ctx, finalize, wrapResponse)
|
|
3831
|
+
: wrapResponse(error),
|
|
3832
|
+
(err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse),
|
|
3833
|
+
)
|
|
3834
|
+
}
|
|
3835
|
+
if (queryError !== undefined) return wrapResponse(queryError)
|
|
3836
|
+
return this.runLifecycleHooks(entry, ctx, finalize, wrapResponse)
|
|
3837
|
+
}
|
|
3838
|
+
|
|
3839
|
+
private runLifecycleAfterParamsResult<T>(
|
|
3840
|
+
entry: RouteEntry,
|
|
3841
|
+
source: RequestSource,
|
|
3842
|
+
ctx: RawContext,
|
|
3843
|
+
finalize: (result: unknown, set: CtxSet) => T,
|
|
3844
|
+
wrapResponse: (response: Response) => T,
|
|
3845
|
+
result: StandardResult<unknown>,
|
|
3846
|
+
): MaybePromise<T> {
|
|
3847
|
+
try {
|
|
3848
|
+
return this.runLifecycleAfterParams(
|
|
3849
|
+
entry,
|
|
3850
|
+
source,
|
|
3851
|
+
ctx,
|
|
3852
|
+
finalize,
|
|
3853
|
+
wrapResponse,
|
|
3854
|
+
this.applyLifecycleValidation(entry, result, ctx, "params"),
|
|
3855
|
+
)
|
|
3856
|
+
} catch (err) {
|
|
3857
|
+
return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse)
|
|
3858
|
+
}
|
|
3859
|
+
}
|
|
3860
|
+
|
|
3861
|
+
private runLifecycleAfterParams<T>(
|
|
3862
|
+
entry: RouteEntry,
|
|
3863
|
+
source: RequestSource,
|
|
3864
|
+
ctx: RawContext,
|
|
3865
|
+
finalize: (result: unknown, set: CtxSet) => T,
|
|
3866
|
+
wrapResponse: (response: Response) => T,
|
|
3867
|
+
paramsError: MaybePromise<Response | undefined>,
|
|
3868
|
+
): MaybePromise<T> {
|
|
3869
|
+
if (paramsError instanceof Promise) {
|
|
3870
|
+
return paramsError.then(
|
|
3871
|
+
(error) =>
|
|
3872
|
+
this.runLifecycleAfterParamsSettled(entry, source, ctx, finalize, wrapResponse, error),
|
|
3873
|
+
(err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse),
|
|
3874
|
+
)
|
|
3875
|
+
}
|
|
3876
|
+
return this.runLifecycleAfterParamsSettled(
|
|
3877
|
+
entry,
|
|
3878
|
+
source,
|
|
3879
|
+
ctx,
|
|
3880
|
+
finalize,
|
|
3881
|
+
wrapResponse,
|
|
3882
|
+
paramsError,
|
|
3883
|
+
)
|
|
3884
|
+
}
|
|
3885
|
+
|
|
3886
|
+
private runLifecycleAfterParamsSettled<T>(
|
|
3887
|
+
entry: RouteEntry,
|
|
3888
|
+
source: RequestSource,
|
|
3889
|
+
ctx: RawContext,
|
|
3890
|
+
finalize: (result: unknown, set: CtxSet) => T,
|
|
3891
|
+
wrapResponse: (response: Response) => T,
|
|
3892
|
+
paramsError: Response | undefined,
|
|
3893
|
+
): MaybePromise<T> {
|
|
3894
|
+
if (paramsError !== undefined) return wrapResponse(paramsError)
|
|
3895
|
+
if (entry.schema?.body !== undefined) {
|
|
3896
|
+
return this.readAndValidateBody(source, entry, ctx).then(
|
|
3897
|
+
(bodyError) => this.runLifecycleAfterBody(entry, ctx, finalize, wrapResponse, bodyError),
|
|
3898
|
+
(err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse),
|
|
3899
|
+
)
|
|
3900
|
+
}
|
|
3901
|
+
return this.runLifecycleAfterBody(entry, ctx, finalize, wrapResponse, undefined)
|
|
3902
|
+
}
|
|
3903
|
+
|
|
3904
|
+
private runLifecycleAfterBody<T>(
|
|
3905
|
+
entry: RouteEntry,
|
|
3906
|
+
ctx: RawContext,
|
|
3907
|
+
finalize: (result: unknown, set: CtxSet) => T,
|
|
3908
|
+
wrapResponse: (response: Response) => T,
|
|
3909
|
+
bodyError: Response | undefined,
|
|
3910
|
+
): MaybePromise<T> {
|
|
3911
|
+
if (bodyError !== undefined) return wrapResponse(bodyError)
|
|
3912
|
+
const querySchema = entry.schema?.query
|
|
3913
|
+
if (querySchema === undefined) return this.runLifecycleHooks(entry, ctx, finalize, wrapResponse)
|
|
3914
|
+
const validation = querySchema["~standard"].validate(queryObjectOf(ctx[CONTEXT_SEARCH]))
|
|
3915
|
+
if (validation instanceof Promise) {
|
|
3916
|
+
return validation.then(
|
|
3917
|
+
(result) => this.runLifecycleAfterQueryResult(entry, ctx, finalize, wrapResponse, result),
|
|
3918
|
+
(err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse),
|
|
3919
|
+
)
|
|
3920
|
+
}
|
|
3921
|
+
return this.runLifecycleAfterQueryResult(entry, ctx, finalize, wrapResponse, validation)
|
|
3922
|
+
}
|
|
3923
|
+
|
|
3924
|
+
private runLifecycleAfterQueryResult<T>(
|
|
3925
|
+
entry: RouteEntry,
|
|
3926
|
+
ctx: RawContext,
|
|
3927
|
+
finalize: (result: unknown, set: CtxSet) => T,
|
|
3928
|
+
wrapResponse: (response: Response) => T,
|
|
3929
|
+
result: StandardResult<unknown>,
|
|
3930
|
+
): MaybePromise<T> {
|
|
3931
|
+
try {
|
|
3932
|
+
const queryError = this.applyLifecycleValidation(entry, result, ctx, "query")
|
|
3933
|
+
if (queryError instanceof Promise) {
|
|
3934
|
+
return queryError.then(
|
|
3935
|
+
(error) => this.runLifecycleAfterQuery(entry, ctx, finalize, wrapResponse, error),
|
|
3936
|
+
(err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse),
|
|
3937
|
+
)
|
|
2824
3938
|
}
|
|
3939
|
+
return this.runLifecycleAfterQuery(entry, ctx, finalize, wrapResponse, queryError)
|
|
3940
|
+
} catch (err) {
|
|
3941
|
+
return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse)
|
|
3942
|
+
}
|
|
3943
|
+
}
|
|
2825
3944
|
|
|
2826
|
-
|
|
2827
|
-
|
|
2828
|
-
|
|
2829
|
-
|
|
2830
|
-
|
|
2831
|
-
|
|
2832
|
-
|
|
2833
|
-
|
|
2834
|
-
|
|
2835
|
-
|
|
2836
|
-
|
|
2837
|
-
|
|
2838
|
-
|
|
2839
|
-
|
|
2840
|
-
|
|
2841
|
-
|
|
2842
|
-
|
|
2843
|
-
|
|
2844
|
-
|
|
3945
|
+
private runLifecycleAfterQuery<T>(
|
|
3946
|
+
entry: RouteEntry,
|
|
3947
|
+
ctx: RawContext,
|
|
3948
|
+
finalize: (result: unknown, set: CtxSet) => T,
|
|
3949
|
+
wrapResponse: (response: Response) => T,
|
|
3950
|
+
queryError: Response | undefined,
|
|
3951
|
+
): MaybePromise<T> {
|
|
3952
|
+
if (queryError !== undefined) return wrapResponse(queryError)
|
|
3953
|
+
return this.runLifecycleHooks(entry, ctx, finalize, wrapResponse)
|
|
3954
|
+
}
|
|
3955
|
+
|
|
3956
|
+
private runLifecycleHooks<T>(
|
|
3957
|
+
entry: RouteEntry,
|
|
3958
|
+
ctx: RawContext,
|
|
3959
|
+
finalize: (result: unknown, set: CtxSet) => T,
|
|
3960
|
+
wrapResponse: (response: Response) => T,
|
|
3961
|
+
): MaybePromise<T> {
|
|
3962
|
+
if (entry.lifecycleHookLane === "derive-before") {
|
|
3963
|
+
return this.runLifecycleDeriveBefore(entry, ctx, finalize, wrapResponse)
|
|
3964
|
+
}
|
|
3965
|
+
try {
|
|
3966
|
+
if (entry.hasDecorations) Object.assign(ctx, entry.decorations)
|
|
3967
|
+
for (let i = 0; i < entry.derives.length; i++) {
|
|
3968
|
+
const extension = entry.derives[i]!(ctx)
|
|
3969
|
+
if (extension instanceof Promise) {
|
|
3970
|
+
return this.continueLifecycleAfterDerive(entry, ctx, finalize, wrapResponse, i, extension)
|
|
2845
3971
|
}
|
|
2846
|
-
|
|
2847
|
-
|
|
2848
|
-
|
|
2849
|
-
|
|
2850
|
-
|
|
2851
|
-
|
|
3972
|
+
Object.assign(ctx, extension)
|
|
3973
|
+
}
|
|
3974
|
+
for (let i = 0; i < entry.beforeHandle.length; i++) {
|
|
3975
|
+
const outcome = entry.beforeHandle[i]!(ctx)
|
|
3976
|
+
if (outcome instanceof Promise) {
|
|
3977
|
+
return this.continueLifecycleAfterBefore(entry, ctx, finalize, wrapResponse, i, outcome)
|
|
2852
3978
|
}
|
|
2853
|
-
|
|
3979
|
+
if (outcome !== undefined) return finalize(outcome, responseSet(ctx))
|
|
2854
3980
|
}
|
|
3981
|
+
const result = entry.handler(ctx)
|
|
3982
|
+
if (result instanceof Promise) {
|
|
3983
|
+
return result.then(
|
|
3984
|
+
(value) => this.finishLifecycleResult(entry, ctx, finalize, wrapResponse, value),
|
|
3985
|
+
(err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse),
|
|
3986
|
+
)
|
|
3987
|
+
}
|
|
3988
|
+
return this.finishLifecycleResult(entry, ctx, finalize, wrapResponse, result)
|
|
3989
|
+
} catch (err) {
|
|
3990
|
+
return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse)
|
|
3991
|
+
}
|
|
3992
|
+
}
|
|
3993
|
+
|
|
3994
|
+
/** Registration-specialized derive → before → handler lifecycle. */
|
|
3995
|
+
private runLifecycleDeriveBefore<T>(
|
|
3996
|
+
entry: RouteEntry,
|
|
3997
|
+
ctx: RawContext,
|
|
3998
|
+
finalize: (result: unknown, set: CtxSet) => T,
|
|
3999
|
+
wrapResponse: (response: Response) => T,
|
|
4000
|
+
): MaybePromise<T> {
|
|
4001
|
+
try {
|
|
4002
|
+
const extension = entry.derives[0]!(ctx)
|
|
4003
|
+
if (extension instanceof Promise) {
|
|
4004
|
+
return this.continueLifecycleAfterDerive(entry, ctx, finalize, wrapResponse, 0, extension)
|
|
4005
|
+
}
|
|
4006
|
+
Object.assign(ctx, extension)
|
|
2855
4007
|
|
|
4008
|
+
const outcome = entry.beforeHandle[0]!(ctx)
|
|
4009
|
+
if (outcome instanceof Promise) {
|
|
4010
|
+
return this.continueLifecycleAfterBefore(entry, ctx, finalize, wrapResponse, 0, outcome)
|
|
4011
|
+
}
|
|
4012
|
+
if (outcome !== undefined)
|
|
4013
|
+
return this.finishSimpleLifecycleResult(entry, ctx, finalize, wrapResponse, outcome)
|
|
4014
|
+
|
|
4015
|
+
const result = entry.handler(ctx)
|
|
4016
|
+
if (result instanceof Promise) {
|
|
4017
|
+
return result.then(
|
|
4018
|
+
(value) => this.finishSimpleLifecycleResult(entry, ctx, finalize, wrapResponse, value),
|
|
4019
|
+
(err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse),
|
|
4020
|
+
)
|
|
4021
|
+
}
|
|
4022
|
+
return this.finishSimpleLifecycleResult(entry, ctx, finalize, wrapResponse, result)
|
|
4023
|
+
} catch (err) {
|
|
4024
|
+
return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse)
|
|
4025
|
+
}
|
|
4026
|
+
}
|
|
4027
|
+
|
|
4028
|
+
/** Finalize the specialized lane without rechecking its registration-proven invariants. */
|
|
4029
|
+
private finishSimpleLifecycleResult<T>(
|
|
4030
|
+
entry: RouteEntry,
|
|
4031
|
+
ctx: RawContext,
|
|
4032
|
+
finalize: (result: unknown, set: CtxSet) => T,
|
|
4033
|
+
wrapResponse: (response: Response) => T,
|
|
4034
|
+
result: unknown,
|
|
4035
|
+
): MaybePromise<T> {
|
|
4036
|
+
try {
|
|
2856
4037
|
return finalize(result, responseSet(ctx))
|
|
2857
4038
|
} catch (err) {
|
|
2858
4039
|
return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse)
|
|
2859
4040
|
}
|
|
2860
4041
|
}
|
|
2861
4042
|
|
|
4043
|
+
private async continueLifecycleAfterDerive<T>(
|
|
4044
|
+
entry: RouteEntry,
|
|
4045
|
+
ctx: RawContext,
|
|
4046
|
+
finalize: (result: unknown, set: CtxSet) => T,
|
|
4047
|
+
wrapResponse: (response: Response) => T,
|
|
4048
|
+
firstIndex: number,
|
|
4049
|
+
first: Promise<unknown>,
|
|
4050
|
+
): Promise<T> {
|
|
4051
|
+
try {
|
|
4052
|
+
Object.assign(ctx, await first)
|
|
4053
|
+
for (let i = firstIndex + 1; i < entry.derives.length; i++) {
|
|
4054
|
+
const extension = entry.derives[i]!(ctx)
|
|
4055
|
+
Object.assign(ctx, extension instanceof Promise ? await extension : extension)
|
|
4056
|
+
}
|
|
4057
|
+
return await this.runLifecycleHooksAsync(entry, ctx, finalize, wrapResponse)
|
|
4058
|
+
} catch (err) {
|
|
4059
|
+
return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse)
|
|
4060
|
+
}
|
|
4061
|
+
}
|
|
4062
|
+
|
|
4063
|
+
private async runLifecycleHooksAsync<T>(
|
|
4064
|
+
entry: RouteEntry,
|
|
4065
|
+
ctx: RawContext,
|
|
4066
|
+
finalize: (result: unknown, set: CtxSet) => T,
|
|
4067
|
+
wrapResponse: (response: Response) => T,
|
|
4068
|
+
): Promise<T> {
|
|
4069
|
+
try {
|
|
4070
|
+
for (const hook of entry.beforeHandle) {
|
|
4071
|
+
const outcome = hook(ctx)
|
|
4072
|
+
const early = outcome instanceof Promise ? await outcome : outcome
|
|
4073
|
+
if (early !== undefined) return finalize(early, responseSet(ctx))
|
|
4074
|
+
}
|
|
4075
|
+
return await this.runLifecycleHandlerAsync(entry, ctx, finalize, wrapResponse)
|
|
4076
|
+
} catch (err) {
|
|
4077
|
+
return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse)
|
|
4078
|
+
}
|
|
4079
|
+
}
|
|
4080
|
+
|
|
4081
|
+
private async continueLifecycleAfterBefore<T>(
|
|
4082
|
+
entry: RouteEntry,
|
|
4083
|
+
ctx: RawContext,
|
|
4084
|
+
finalize: (result: unknown, set: CtxSet) => T,
|
|
4085
|
+
wrapResponse: (response: Response) => T,
|
|
4086
|
+
firstIndex: number,
|
|
4087
|
+
first: Promise<unknown>,
|
|
4088
|
+
): Promise<T> {
|
|
4089
|
+
try {
|
|
4090
|
+
const firstOutcome = await first
|
|
4091
|
+
if (firstOutcome !== undefined) return finalize(firstOutcome, responseSet(ctx))
|
|
4092
|
+
for (let i = firstIndex + 1; i < entry.beforeHandle.length; i++) {
|
|
4093
|
+
const outcome = entry.beforeHandle[i]!(ctx)
|
|
4094
|
+
const early = outcome instanceof Promise ? await outcome : outcome
|
|
4095
|
+
if (early !== undefined) return finalize(early, responseSet(ctx))
|
|
4096
|
+
}
|
|
4097
|
+
return await this.runLifecycleHandlerAsync(entry, ctx, finalize, wrapResponse)
|
|
4098
|
+
} catch (err) {
|
|
4099
|
+
return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse)
|
|
4100
|
+
}
|
|
4101
|
+
}
|
|
4102
|
+
|
|
4103
|
+
private async runLifecycleHandlerAsync<T>(
|
|
4104
|
+
entry: RouteEntry,
|
|
4105
|
+
ctx: RawContext,
|
|
4106
|
+
finalize: (result: unknown, set: CtxSet) => T,
|
|
4107
|
+
wrapResponse: (response: Response) => T,
|
|
4108
|
+
): Promise<T> {
|
|
4109
|
+
try {
|
|
4110
|
+
const output = entry.handler(ctx)
|
|
4111
|
+
const result = output instanceof Promise ? await output : output
|
|
4112
|
+
return await this.finishLifecycleResult(entry, ctx, finalize, wrapResponse, result)
|
|
4113
|
+
} catch (err) {
|
|
4114
|
+
return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse)
|
|
4115
|
+
}
|
|
4116
|
+
}
|
|
4117
|
+
|
|
4118
|
+
private finishLifecycleResult<T>(
|
|
4119
|
+
entry: RouteEntry,
|
|
4120
|
+
ctx: RawContext,
|
|
4121
|
+
finalize: (result: unknown, set: CtxSet) => T,
|
|
4122
|
+
wrapResponse: (response: Response) => T,
|
|
4123
|
+
initial: unknown,
|
|
4124
|
+
): MaybePromise<T> {
|
|
4125
|
+
try {
|
|
4126
|
+
let result = initial
|
|
4127
|
+
for (let i = 0; i < entry.afterHandle.length; i++) {
|
|
4128
|
+
const transformed = entry.afterHandle[i]!(result, ctx)
|
|
4129
|
+
if (transformed instanceof Promise) {
|
|
4130
|
+
return this.continueLifecycleAfterHandle(
|
|
4131
|
+
entry,
|
|
4132
|
+
ctx,
|
|
4133
|
+
finalize,
|
|
4134
|
+
wrapResponse,
|
|
4135
|
+
i,
|
|
4136
|
+
transformed,
|
|
4137
|
+
)
|
|
4138
|
+
}
|
|
4139
|
+
result = transformed
|
|
4140
|
+
}
|
|
4141
|
+
return this.finishLifecycleContract(entry, ctx, finalize, wrapResponse, result)
|
|
4142
|
+
} catch (err) {
|
|
4143
|
+
return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse)
|
|
4144
|
+
}
|
|
4145
|
+
}
|
|
4146
|
+
|
|
4147
|
+
private async continueLifecycleAfterHandle<T>(
|
|
4148
|
+
entry: RouteEntry,
|
|
4149
|
+
ctx: RawContext,
|
|
4150
|
+
finalize: (result: unknown, set: CtxSet) => T,
|
|
4151
|
+
wrapResponse: (response: Response) => T,
|
|
4152
|
+
firstIndex: number,
|
|
4153
|
+
first: Promise<unknown>,
|
|
4154
|
+
): Promise<T> {
|
|
4155
|
+
try {
|
|
4156
|
+
let result = await first
|
|
4157
|
+
for (let i = firstIndex + 1; i < entry.afterHandle.length; i++) {
|
|
4158
|
+
const transformed = entry.afterHandle[i]!(result, ctx)
|
|
4159
|
+
result = transformed instanceof Promise ? await transformed : transformed
|
|
4160
|
+
}
|
|
4161
|
+
return await this.finishLifecycleContract(entry, ctx, finalize, wrapResponse, result)
|
|
4162
|
+
} catch (err) {
|
|
4163
|
+
return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse)
|
|
4164
|
+
}
|
|
4165
|
+
}
|
|
4166
|
+
|
|
4167
|
+
private finishLifecycleContract<T>(
|
|
4168
|
+
entry: RouteEntry,
|
|
4169
|
+
ctx: RawContext,
|
|
4170
|
+
finalize: (result: unknown, set: CtxSet) => T,
|
|
4171
|
+
wrapResponse: (response: Response) => T,
|
|
4172
|
+
result: unknown,
|
|
4173
|
+
): MaybePromise<T> {
|
|
4174
|
+
try {
|
|
4175
|
+
const contract = entry.responseContract
|
|
4176
|
+
if (contract === undefined) return finalize(result, responseSet(ctx))
|
|
4177
|
+
const checked = contract.runtime.check(contract.schema, result)
|
|
4178
|
+
if (checked instanceof Promise) {
|
|
4179
|
+
return checked.then(
|
|
4180
|
+
(outcome) => this.finishContractOutcome(ctx, finalize, wrapResponse, outcome),
|
|
4181
|
+
(err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse),
|
|
4182
|
+
)
|
|
4183
|
+
}
|
|
4184
|
+
return this.finishContractOutcome(ctx, finalize, wrapResponse, checked)
|
|
4185
|
+
} catch (err) {
|
|
4186
|
+
return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse)
|
|
4187
|
+
}
|
|
4188
|
+
}
|
|
4189
|
+
|
|
4190
|
+
private finishContractOutcome<T>(
|
|
4191
|
+
ctx: RawContext,
|
|
4192
|
+
finalize: (result: unknown, set: CtxSet) => T,
|
|
4193
|
+
wrapResponse: (response: Response) => T,
|
|
4194
|
+
outcome: {
|
|
4195
|
+
readonly kind: "ok" | "warn" | "violation"
|
|
4196
|
+
readonly value?: unknown
|
|
4197
|
+
readonly message?: string
|
|
4198
|
+
},
|
|
4199
|
+
): T {
|
|
4200
|
+
if (outcome.kind === "violation") {
|
|
4201
|
+
this.logger.error("response contract violation", {
|
|
4202
|
+
method: ctx.req.method,
|
|
4203
|
+
path: pathnameOf(ctx.req.url),
|
|
4204
|
+
detail: outcome.message,
|
|
4205
|
+
})
|
|
4206
|
+
return wrapResponse(jsonError(500, "internal_error"))
|
|
4207
|
+
}
|
|
4208
|
+
if (outcome.kind === "warn") {
|
|
4209
|
+
this.logger.warn("response contract", {
|
|
4210
|
+
method: ctx.req.method,
|
|
4211
|
+
path: pathnameOf(ctx.req.url),
|
|
4212
|
+
detail: outcome.message,
|
|
4213
|
+
})
|
|
4214
|
+
}
|
|
4215
|
+
return finalize(outcome.value, responseSet(ctx))
|
|
4216
|
+
}
|
|
4217
|
+
|
|
2862
4218
|
private async handleLifecycleError<T>(
|
|
2863
4219
|
entry: RouteEntry,
|
|
2864
4220
|
err: unknown,
|
|
@@ -2925,12 +4281,12 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2925
4281
|
|
|
2926
4282
|
/** Apply validation and its recovery hook on the generic lifecycle lane. Recovery is completed
|
|
2927
4283
|
* before derives/beforeHandle run, matching the body-only and query-only execution lanes. */
|
|
2928
|
-
private
|
|
4284
|
+
private applyLifecycleValidation(
|
|
2929
4285
|
entry: RouteEntry,
|
|
2930
4286
|
result: StandardResult<unknown>,
|
|
2931
4287
|
ctx: RawContext,
|
|
2932
4288
|
kind: "body" | "query" | "params",
|
|
2933
|
-
):
|
|
4289
|
+
): MaybePromise<Response | undefined> {
|
|
2934
4290
|
const assign = (value: unknown): void => {
|
|
2935
4291
|
if (kind === "body") ctx.body = value
|
|
2936
4292
|
else if (kind === "query") ctx.query = value
|
|
@@ -2943,8 +4299,22 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2943
4299
|
const hook = entry.schema?.onValidationError ?? this.defaultOnValidationError
|
|
2944
4300
|
if (hook === undefined) return validationError(result.issues)
|
|
2945
4301
|
const attempted = hook(result.issues, ctx as unknown as Context, kind)
|
|
2946
|
-
|
|
2947
|
-
|
|
4302
|
+
if (attempted instanceof Promise) {
|
|
4303
|
+
return attempted.then((recovery) =>
|
|
4304
|
+
this.finishLifecycleValidationRecovery(entry, kind, result.issues!, recovery, assign),
|
|
4305
|
+
)
|
|
4306
|
+
}
|
|
4307
|
+
return this.finishLifecycleValidationRecovery(entry, kind, result.issues, attempted, assign)
|
|
4308
|
+
}
|
|
4309
|
+
|
|
4310
|
+
private finishLifecycleValidationRecovery(
|
|
4311
|
+
entry: RouteEntry,
|
|
4312
|
+
kind: "body" | "query" | "params",
|
|
4313
|
+
issues: ReadonlyArray<StandardIssue>,
|
|
4314
|
+
recovery: unknown,
|
|
4315
|
+
assign: (value: unknown) => void,
|
|
4316
|
+
): MaybePromise<Response | undefined> {
|
|
4317
|
+
if (recovery === undefined) return validationError(issues)
|
|
2948
4318
|
if (recovery instanceof Response) return recovery
|
|
2949
4319
|
const schema =
|
|
2950
4320
|
kind === "body"
|
|
@@ -2953,9 +4323,15 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2953
4323
|
? entry.schema?.query
|
|
2954
4324
|
: entry.schema?.params
|
|
2955
4325
|
const retried = schema!["~standard"].validate(recovery)
|
|
2956
|
-
|
|
2957
|
-
|
|
2958
|
-
|
|
4326
|
+
if (retried instanceof Promise) {
|
|
4327
|
+
return retried.then((settled) => {
|
|
4328
|
+
if (settled.issues !== undefined) return validationError(settled.issues)
|
|
4329
|
+
assign(settled.value)
|
|
4330
|
+
return undefined
|
|
4331
|
+
})
|
|
4332
|
+
}
|
|
4333
|
+
if (retried.issues !== undefined) return validationError(retried.issues)
|
|
4334
|
+
assign(retried.value)
|
|
2959
4335
|
return undefined
|
|
2960
4336
|
}
|
|
2961
4337
|
|