@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/dist/server/server.js
CHANGED
|
@@ -6,13 +6,153 @@ import { RouteCatalog } from "../internal/route-catalog.js";
|
|
|
6
6
|
import { isSameOriginRequest } from "../internal/same-origin.js";
|
|
7
7
|
import { compileRoutePattern, decodeRouteParams } from "../router/pattern.js";
|
|
8
8
|
import { EMPTY_PARAMS, Router } from "../router/router.js";
|
|
9
|
-
import { parseContentLength } from "./body.js";
|
|
9
|
+
import { assertByteLimit, parseContentLength } from "./body.js";
|
|
10
10
|
import { resolveClientIp } from "./client-ip.js";
|
|
11
11
|
import { jsonError, pathnameOf, urlPartsOf } from "./http.js";
|
|
12
|
+
import { withStaticNodeHeaders } from "./node-outcome.js";
|
|
13
|
+
import { recordHeadersView, } from "./node-outcome-hook.js";
|
|
12
14
|
import { isUrlEncodedForm, queryObjectOf, readBoundedForm, searchOf, } from "./query.js";
|
|
13
15
|
import { RequestContext, readBoundedJsonSource } from "./request-context.js";
|
|
14
|
-
import { fusedRespond, fusedRespondNoSet, toResponse } from "./respond.js";
|
|
16
|
+
import { applyStaticResponseHeaders, buildStaticResponseHeaders, fusedRespond, fusedRespondNoSet, knownMutableHeaders, markTaggedResponse, rememberMutableHeaders, taggedResponseBody, taggedResponseOwner, toResponse, } from "./respond.js";
|
|
15
17
|
import { CONTEXT_SEARCH, CONTEXT_SET, EMPTY_RESPONSE_CONTROLS, getNeverAbortSignal, getUnboundedRequestBudget, headerOf, requestOf, } from "./runtime-core.js";
|
|
18
|
+
import { normalizeStaticResponseHeaders } from "./static-headers.js";
|
|
19
|
+
function isBodylessStatus(status) {
|
|
20
|
+
return status === 204 || status === 205 || status === 304;
|
|
21
|
+
}
|
|
22
|
+
/** Apply a body hook's return to the native response context (bytes, or a structured replacement). */
|
|
23
|
+
function applyBodyReplacement(response, replaced) {
|
|
24
|
+
if (replaced === undefined)
|
|
25
|
+
return;
|
|
26
|
+
if (typeof replaced === "string" || replaced instanceof Uint8Array) {
|
|
27
|
+
response.body = isBodylessStatus(response.status) ? null : replaced;
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
if (replaced.body !== undefined)
|
|
31
|
+
response.body = replaced.body;
|
|
32
|
+
if (replaced.status !== undefined)
|
|
33
|
+
response.status = replaced.status;
|
|
34
|
+
if (isBodylessStatus(response.status))
|
|
35
|
+
response.body = null;
|
|
36
|
+
}
|
|
37
|
+
/** Swap a tagged Response's body for a hook's replacement, re-tagging so later body hooks (and the
|
|
38
|
+
* Node fallback's direct writer) see the new bytes; explicit lengths are dropped so framing is
|
|
39
|
+
* re-derived from what actually ships. */
|
|
40
|
+
function withReplacedBody(response, replaced) {
|
|
41
|
+
if (replaced === undefined)
|
|
42
|
+
return response;
|
|
43
|
+
const originalBody = taggedResponseBody(response);
|
|
44
|
+
let body;
|
|
45
|
+
let status = response.status;
|
|
46
|
+
if (typeof replaced === "string" || replaced instanceof Uint8Array) {
|
|
47
|
+
body = replaced;
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
body = replaced.body !== undefined ? replaced.body : (originalBody ?? null);
|
|
51
|
+
if (replaced.status !== undefined)
|
|
52
|
+
status = replaced.status;
|
|
53
|
+
if (body === (originalBody ?? null) && status === response.status) {
|
|
54
|
+
return response;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if (isBodylessStatus(status))
|
|
58
|
+
body = null;
|
|
59
|
+
const headers = new Headers(response.headers);
|
|
60
|
+
headers.delete("content-length");
|
|
61
|
+
const next = new Response(body, {
|
|
62
|
+
status,
|
|
63
|
+
headers,
|
|
64
|
+
});
|
|
65
|
+
if (body !== null)
|
|
66
|
+
markTaggedResponse(next, body, taggedResponseOwner(response));
|
|
67
|
+
return next;
|
|
68
|
+
}
|
|
69
|
+
const HEADER_MUTABILITY_PROBE = "x-nifra-header-probe";
|
|
70
|
+
const GUARDED_RESPONSE_HEADERS = new WeakSet();
|
|
71
|
+
/**
|
|
72
|
+
* Detect a guarded Web Headers object before invoking user code. Retrying after catching any
|
|
73
|
+
* TypeError is observably wrong: user hooks are allowed to throw TypeError themselves, and a retry
|
|
74
|
+
* runs those hooks twice. The hot path never reaches the probe: every framework-constructed
|
|
75
|
+
* Response stamps its headers as known-mutable at construction (see respond.ts), so only a
|
|
76
|
+
* handler-returned foreign `Response` - the case that can actually be guarded - pays it, once per
|
|
77
|
+
* headers object. The probe uses a valid private header name and restores a pre-existing value, so
|
|
78
|
+
* the fallback is limited to the actual immutable/guarded-header case.
|
|
79
|
+
*/
|
|
80
|
+
function hasMutableResponseHeaders(headers) {
|
|
81
|
+
if (knownMutableHeaders(headers))
|
|
82
|
+
return true;
|
|
83
|
+
if (GUARDED_RESPONSE_HEADERS.has(headers))
|
|
84
|
+
return false;
|
|
85
|
+
let previous = null;
|
|
86
|
+
try {
|
|
87
|
+
previous = headers.get(HEADER_MUTABILITY_PROBE);
|
|
88
|
+
headers.set(HEADER_MUTABILITY_PROBE, "1");
|
|
89
|
+
if (previous === null)
|
|
90
|
+
headers.delete(HEADER_MUTABILITY_PROBE);
|
|
91
|
+
else
|
|
92
|
+
headers.set(HEADER_MUTABILITY_PROBE, previous);
|
|
93
|
+
rememberMutableHeaders(headers);
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
try {
|
|
98
|
+
if (previous === null)
|
|
99
|
+
headers.delete(HEADER_MUTABILITY_PROBE);
|
|
100
|
+
else
|
|
101
|
+
headers.set(HEADER_MUTABILITY_PROBE, previous);
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
// The guarded object rejected the cleanup too; the clone below is authoritative.
|
|
105
|
+
}
|
|
106
|
+
GUARDED_RESPONSE_HEADERS.add(headers);
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
/** Adapt a portable {@link ResponseBodyHook} into the Web `onResponse` walk. Only a Response
|
|
111
|
+
* carrying the framework-buffered body tag participates - a raw or streamed Response is skipped by
|
|
112
|
+
* contract, never drained. */
|
|
113
|
+
function webResponseBodyHook(fn, owners) {
|
|
114
|
+
return (response, req) => {
|
|
115
|
+
const body = taggedResponseBody(response, owners);
|
|
116
|
+
if (body === undefined)
|
|
117
|
+
return response;
|
|
118
|
+
const out = fn(body, response.headers, webRequestView(req), response.status);
|
|
119
|
+
if (out instanceof Promise)
|
|
120
|
+
return out.then((replaced) => withReplacedBody(response, replaced));
|
|
121
|
+
return withReplacedBody(response, out);
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
/** Minimal request view over a Web `Request` for portable header hooks on the Web serving paths. */
|
|
125
|
+
function webRequestView(req) {
|
|
126
|
+
return { method: req.method, url: req.url, header: (name) => req.headers.get(name) };
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Adapt a portable {@link ResponseHeadersHook} into the Web `onResponse` walk: run it against the
|
|
130
|
+
* response's own `Headers` in place (no clone). A response whose headers are GUARDED (a raw
|
|
131
|
+
* `fetch()`ed Response returned by a handler) throws on the FIRST mutation, so nothing was applied
|
|
132
|
+
* yet - rerun once against a mutable copy. Async hooks should perform their mutations before their
|
|
133
|
+
* first await for the guard fallback to cover them.
|
|
134
|
+
*/
|
|
135
|
+
function webResponseHeadersHook(fn) {
|
|
136
|
+
return (response, req) => {
|
|
137
|
+
const view = webRequestView(req);
|
|
138
|
+
if (!hasMutableResponseHeaders(response.headers)) {
|
|
139
|
+
const clone = new Response(response.body, response);
|
|
140
|
+
const out = fn(clone.headers, view, clone.status);
|
|
141
|
+
return out instanceof Promise ? out.then(() => clone) : clone;
|
|
142
|
+
}
|
|
143
|
+
const out = fn(response.headers, view, response.status);
|
|
144
|
+
return out instanceof Promise ? out.then(() => response) : response;
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
/** Adapt a raw-response fallback hook. Tagged framework payloads already ran through the body tier,
|
|
148
|
+
* while untagged Responses include streams, proxied fetches, and framework-generated error responses. */
|
|
149
|
+
function webResponseRawHook(fn, owners) {
|
|
150
|
+
return (response, req) => {
|
|
151
|
+
if (taggedResponseBody(response, owners) !== undefined)
|
|
152
|
+
return response;
|
|
153
|
+
return fn(response, req);
|
|
154
|
+
};
|
|
155
|
+
}
|
|
16
156
|
import { INSTALL_EFFECT_LEDGER, INSTALL_IDEMPOTENCY, INSTALL_MCP, INSTALL_NODE_DIRECT, INSTALL_RESPONSE_CONTRACT, INSTALL_SSE, INSTALL_WS, } from "./install.js";
|
|
17
157
|
import { jsonLogger } from "./logger.js";
|
|
18
158
|
const functionToString = Function.prototype.toString;
|
|
@@ -163,9 +303,34 @@ export class Server {
|
|
|
163
303
|
onErrorHooks;
|
|
164
304
|
aroundHooks;
|
|
165
305
|
onRequestHooks;
|
|
306
|
+
onNodeRequestHooks;
|
|
307
|
+
/** Registration-time eligibility for the Node request twin lane. */
|
|
308
|
+
nodeRequestHooksComplete;
|
|
166
309
|
onResponseHooks;
|
|
310
|
+
onNodeResponseHooks;
|
|
311
|
+
/** Registration-time eligibility for the Node response twin lane. */
|
|
312
|
+
nodeResponseHooksComplete;
|
|
167
313
|
onResponseFinalizedHooks;
|
|
314
|
+
/**
|
|
315
|
+
* Statically declared response headers, merged and prebuilt at registration; `undefined` (the
|
|
316
|
+
* default) leaves every render path in its original shape. These are NOT response hooks - they are
|
|
317
|
+
* folded into response construction - so declaring them keeps the fused native lanes an
|
|
318
|
+
* `onResponse` hook would have disabled.
|
|
319
|
+
*/
|
|
320
|
+
staticResponseHeaders;
|
|
321
|
+
/** `wrapResponse` for the Web lanes: identity until static headers exist to fold into the
|
|
322
|
+
* framework's own error/404/timeout renders, which are built outside the header init. */
|
|
323
|
+
wrapWebResponse;
|
|
324
|
+
webResponseTimeout;
|
|
325
|
+
/** Body/raw response hooks need a framework-payload marker; keep that decision per app. */
|
|
326
|
+
responseBodyTag;
|
|
327
|
+
responseBodyOwners;
|
|
328
|
+
finalizeResponse = (result, set) => toResponse(result, set, this.responseBodyTag, this.staticResponseHeaders);
|
|
168
329
|
responseRequests;
|
|
330
|
+
/** Original source → request observed by generic request hooks, including in-place mutations. */
|
|
331
|
+
responseSources;
|
|
332
|
+
/** Memoized NodeRequestContext per plain-`Request` source - see {@link nodeRequestContextOf}. */
|
|
333
|
+
nodeContexts;
|
|
169
334
|
/** Names of plugins/middleware already applied via `use` - for idempotent dedupe. */
|
|
170
335
|
appliedPlugins;
|
|
171
336
|
/** Order-scoped evidence captured by routes registered after an assured plugin. */
|
|
@@ -180,8 +345,12 @@ export class Server {
|
|
|
180
345
|
this.wsRouter = new Router();
|
|
181
346
|
this.wsRouteCount = 0;
|
|
182
347
|
this.topics = undefined;
|
|
183
|
-
|
|
184
|
-
|
|
348
|
+
const maxBodyBytes = options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES;
|
|
349
|
+
assertByteLimit(maxBodyBytes, "maxBodyBytes");
|
|
350
|
+
const wsMaxPayloadBytes = options.wsMaxPayloadBytes ?? maxBodyBytes;
|
|
351
|
+
assertByteLimit(wsMaxPayloadBytes, "wsMaxPayloadBytes");
|
|
352
|
+
this.maxBodyBytes = maxBodyBytes;
|
|
353
|
+
this.wsMaxPayloadBytes = wsMaxPayloadBytes;
|
|
185
354
|
this.requestTimeoutMs = options.requestTimeoutMs ?? 0;
|
|
186
355
|
this.clientIpTrust = options.clientIp;
|
|
187
356
|
this.acceptInboundDeadlines = options.acceptInboundDeadlines ?? false;
|
|
@@ -219,9 +388,20 @@ export class Server {
|
|
|
219
388
|
this.onErrorHooks = [];
|
|
220
389
|
this.aroundHooks = [];
|
|
221
390
|
this.onRequestHooks = [];
|
|
391
|
+
this.onNodeRequestHooks = [];
|
|
392
|
+
this.nodeRequestHooksComplete = true;
|
|
222
393
|
this.onResponseHooks = [];
|
|
394
|
+
this.onNodeResponseHooks = [];
|
|
395
|
+
this.nodeResponseHooksComplete = true;
|
|
223
396
|
this.onResponseFinalizedHooks = [];
|
|
397
|
+
this.staticResponseHeaders = undefined;
|
|
398
|
+
this.wrapWebResponse = IDENTITY_RESPONSE;
|
|
399
|
+
this.webResponseTimeout = RESPONSE_TIMEOUT;
|
|
400
|
+
this.responseBodyTag = undefined;
|
|
401
|
+
this.responseBodyOwners = new Set();
|
|
224
402
|
this.responseRequests = new WeakMap();
|
|
403
|
+
this.responseSources = new WeakMap();
|
|
404
|
+
this.nodeContexts = new WeakMap();
|
|
225
405
|
this.appliedPlugins = new Set();
|
|
226
406
|
this.activeAssurance = [];
|
|
227
407
|
this.globalAssurance = [];
|
|
@@ -252,6 +432,8 @@ export class Server {
|
|
|
252
432
|
onRequest(fn) {
|
|
253
433
|
this.assertConfigurable("onRequest()");
|
|
254
434
|
this.onRequestHooks.push(fn);
|
|
435
|
+
this.onNodeRequestHooks.push(undefined);
|
|
436
|
+
this.nodeRequestHooksComplete = false;
|
|
255
437
|
return this;
|
|
256
438
|
}
|
|
257
439
|
/** Run after validation, before the handler; a non-`undefined` return short-circuits. Order-scoped. */
|
|
@@ -313,6 +495,115 @@ export class Server {
|
|
|
313
495
|
onResponse(fn) {
|
|
314
496
|
this.assertConfigurable("onResponse()");
|
|
315
497
|
this.onResponseHooks.push(fn);
|
|
498
|
+
this.onNodeResponseHooks.push(undefined);
|
|
499
|
+
this.nodeResponseHooksComplete = false;
|
|
500
|
+
return this;
|
|
501
|
+
}
|
|
502
|
+
/**
|
|
503
|
+
* Declare response headers with NO per-request decision behind them - and pay nothing for them on
|
|
504
|
+
* the request path.
|
|
505
|
+
*
|
|
506
|
+
* These are not a hook. Because the values are known at wire-up, they are folded into response
|
|
507
|
+
* construction (one prebuilt init for JSON renders, one record merge where the request set its own
|
|
508
|
+
* headers), so an app whose only response middleware is static keeps every fused and native lane -
|
|
509
|
+
* `onResponse`/`onResponseHeaders` disable Bun's fused native routes and, for a full `onResponse`,
|
|
510
|
+
* the Node direct writer. They apply to EVERY response, exactly as a response hook would: success,
|
|
511
|
+
* error, 404/405, timeout, and short-circuit alike.
|
|
512
|
+
*
|
|
513
|
+
* app.responseHeaders({ "x-frame-options": "DENY", "referrer-policy": "no-referrer" })
|
|
514
|
+
*
|
|
515
|
+
* They are DEFAULTS: a value the request itself produced (`c.set.headers`, or a response hook)
|
|
516
|
+
* wins, whatever casing it used. Names are lowercased once here; a non-string value, an invalid
|
|
517
|
+
* name, `__proto__`, or a name the render owns (`content-type`, `content-length`,
|
|
518
|
+
* `transfer-encoding`, `set-cookie`) throws a `TypeError` at wire-up.
|
|
519
|
+
*
|
|
520
|
+
* ORDERING: declarations made before any response hook fold into one static record. One made AFTER
|
|
521
|
+
* a response hook cannot - the hook may already have written that name and must keep winning - so
|
|
522
|
+
* it registers as an ordinary `onResponseHeaders` hook instead, preserving registration order at
|
|
523
|
+
* the cost of the static tier's speed. Declare static headers first.
|
|
524
|
+
*/
|
|
525
|
+
responseHeaders(record) {
|
|
526
|
+
this.assertConfigurable("responseHeaders()");
|
|
527
|
+
return this.addStaticResponseHeaders(normalizeStaticResponseHeaders(record));
|
|
528
|
+
}
|
|
529
|
+
/** Wire an already-validated lowercase record into the static tier, or - once a dynamic response
|
|
530
|
+
* hook owns part of the header state - into a hook that runs in the right order. */
|
|
531
|
+
addStaticResponseHeaders(record) {
|
|
532
|
+
if (this.onResponseHooks.length > 0) {
|
|
533
|
+
return this.onResponseHeaders((headers) => {
|
|
534
|
+
for (const name of Object.keys(record)) {
|
|
535
|
+
if (!headers.has(name))
|
|
536
|
+
headers.set(name, record[name]);
|
|
537
|
+
}
|
|
538
|
+
});
|
|
539
|
+
}
|
|
540
|
+
const merged = this.staticResponseHeaders === undefined
|
|
541
|
+
? record
|
|
542
|
+
: { ...this.staticResponseHeaders.record, ...record };
|
|
543
|
+
this.staticResponseHeaders = buildStaticResponseHeaders(merged);
|
|
544
|
+
const statics = this.staticResponseHeaders;
|
|
545
|
+
this.wrapWebResponse = (response) => applyStaticResponseHeaders(response, statics);
|
|
546
|
+
this.webResponseTimeout = () => applyStaticResponseHeaders(RESPONSE_TIMEOUT(), statics);
|
|
547
|
+
return this;
|
|
548
|
+
}
|
|
549
|
+
enableResponseBodyTagging() {
|
|
550
|
+
if (this.responseBodyTag === undefined) {
|
|
551
|
+
this.responseBodyTag = Object.freeze({});
|
|
552
|
+
this.responseBodyOwners.add(this.responseBodyTag);
|
|
553
|
+
}
|
|
554
|
+
return this.responseBodyTag;
|
|
555
|
+
}
|
|
556
|
+
/**
|
|
557
|
+
* Register a response transform for raw/streamed Responses. Framework-serialized payloads stay on
|
|
558
|
+
* the body tier; this hook is only entered for untagged Responses (streams, proxied fetches, and
|
|
559
|
+
* framework-generated error responses). A no-op native twin keeps buffered JSON on the direct lane.
|
|
560
|
+
*/
|
|
561
|
+
onResponseRaw(fn) {
|
|
562
|
+
this.assertConfigurable("onResponseRaw()");
|
|
563
|
+
this.enableResponseBodyTagging();
|
|
564
|
+
this.onResponseHooks.push(webResponseRawHook(fn, this.responseBodyOwners));
|
|
565
|
+
this.onNodeResponseHooks.push(() => undefined);
|
|
566
|
+
return this;
|
|
567
|
+
}
|
|
568
|
+
/**
|
|
569
|
+
* Register a PORTABLE header-only response hook - one implementation, fast on every runtime.
|
|
570
|
+
*
|
|
571
|
+
* The hook receives a mutable case-insensitive header view, a minimal request view, and the
|
|
572
|
+
* status. On the Web serving paths it runs inside the normal `onResponse` walk against the
|
|
573
|
+
* response's own `Headers` (mutating in place - no clone). On Node it self-pairs as a native
|
|
574
|
+
* response hook against the outcome's plain header record, so registering one NEVER forces the
|
|
575
|
+
* Node adapter off its direct socket writer the way a full `onResponse(res: Response)` hook does.
|
|
576
|
+
* Prefer this over `onResponse` whenever the middleware only reads/writes headers.
|
|
577
|
+
*/
|
|
578
|
+
onResponseHeaders(fn) {
|
|
579
|
+
this.assertConfigurable("onResponseHeaders()");
|
|
580
|
+
this.onResponseHooks.push(webResponseHeadersHook(fn));
|
|
581
|
+
this.onNodeResponseHooks.push((response, req) => fn(recordHeadersView(response), req, response.status));
|
|
582
|
+
return this;
|
|
583
|
+
}
|
|
584
|
+
/**
|
|
585
|
+
* Register a PORTABLE post-serialization body hook - the payload tier. The hook receives the
|
|
586
|
+
* FINAL framework-serialized bytes (plus the mutable header view and status) and may return
|
|
587
|
+
* replacement bytes; `undefined` keeps the body. On the Node direct writer the bytes come
|
|
588
|
+
* straight off the outcome record; on the Web serving paths they ride the framework-built
|
|
589
|
+
* Response as a tag, so no body stream is ever drained on any runtime. A handler-returned raw
|
|
590
|
+
* `Response` (proxied fetch, SSE, streamed SSR) is skipped by contract - transforming those is
|
|
591
|
+
* what the full `onResponse` hook is for.
|
|
592
|
+
*/
|
|
593
|
+
onResponseBody(fn) {
|
|
594
|
+
this.assertConfigurable("onResponseBody()");
|
|
595
|
+
this.enableResponseBodyTagging();
|
|
596
|
+
this.onResponseHooks.push(webResponseBodyHook(fn, this.responseBodyOwners));
|
|
597
|
+
this.onNodeResponseHooks.push((response, req) => {
|
|
598
|
+
const body = response.body;
|
|
599
|
+
if (body === null)
|
|
600
|
+
return undefined;
|
|
601
|
+
const out = fn(body, recordHeadersView(response), req, response.status);
|
|
602
|
+
if (out instanceof Promise)
|
|
603
|
+
return out.then((replaced) => applyBodyReplacement(response, replaced));
|
|
604
|
+
applyBodyReplacement(response, out);
|
|
605
|
+
return undefined;
|
|
606
|
+
});
|
|
316
607
|
return this;
|
|
317
608
|
}
|
|
318
609
|
/** Observe the terminal response after all transformations. Observers are ordered and fail-open. */
|
|
@@ -359,16 +650,42 @@ export class Server {
|
|
|
359
650
|
}
|
|
360
651
|
this.globalAssurance.push(...evidence.filter((item) => item.scope === "global"));
|
|
361
652
|
this.activeAssurance.push(...evidence.filter((item) => item.scope === "subsequent"));
|
|
362
|
-
if (arg.onRequest !== undefined)
|
|
363
|
-
this.onRequest(
|
|
653
|
+
if (arg.onRequest !== undefined) {
|
|
654
|
+
this.assertConfigurable("onRequest()");
|
|
655
|
+
this.onRequestHooks.push(arg.onRequest);
|
|
656
|
+
this.onNodeRequestHooks.push(arg.onNodeRequest);
|
|
657
|
+
if (arg.onNodeRequest === undefined)
|
|
658
|
+
this.nodeRequestHooksComplete = false;
|
|
659
|
+
}
|
|
660
|
+
else if (arg.onNodeRequest !== undefined) {
|
|
661
|
+
throw new TypeError("onNodeRequest() requires a paired onRequest() hook");
|
|
662
|
+
}
|
|
364
663
|
if (arg.around !== undefined)
|
|
365
664
|
this.around(arg.around);
|
|
366
665
|
if (arg.beforeHandle !== undefined)
|
|
367
666
|
this.beforeHandle(arg.beforeHandle);
|
|
368
667
|
if (arg.afterHandle !== undefined)
|
|
369
668
|
this.afterHandle(arg.afterHandle);
|
|
370
|
-
if (arg.onResponse !== undefined)
|
|
371
|
-
this.onResponse(
|
|
669
|
+
if (arg.onResponse !== undefined) {
|
|
670
|
+
this.assertConfigurable("onResponse()");
|
|
671
|
+
this.onResponseHooks.push(arg.onResponse);
|
|
672
|
+
this.onNodeResponseHooks.push(arg.onNodeResponse);
|
|
673
|
+
if (arg.onNodeResponse === undefined)
|
|
674
|
+
this.nodeResponseHooksComplete = false;
|
|
675
|
+
}
|
|
676
|
+
else if (arg.onNodeResponse !== undefined) {
|
|
677
|
+
throw new TypeError("onNodeResponse() requires a paired onResponse() hook");
|
|
678
|
+
}
|
|
679
|
+
// Before the bundle's own hooks: a bundle declaring both means its static values are the
|
|
680
|
+
// defaults its hook may then override, which is the order a single bundle reads in.
|
|
681
|
+
if (arg.responseHeaders !== undefined)
|
|
682
|
+
this.responseHeaders(arg.responseHeaders);
|
|
683
|
+
if (arg.onResponseHeaders !== undefined)
|
|
684
|
+
this.onResponseHeaders(arg.onResponseHeaders);
|
|
685
|
+
if (arg.onResponseBody !== undefined)
|
|
686
|
+
this.onResponseBody(arg.onResponseBody);
|
|
687
|
+
if (arg.onResponseRaw !== undefined)
|
|
688
|
+
this.onResponseRaw(arg.onResponseRaw);
|
|
372
689
|
if (arg.onResponseFinalized !== undefined)
|
|
373
690
|
this.onResponseFinalized(arg.onResponseFinalized);
|
|
374
691
|
if (arg.onError !== undefined)
|
|
@@ -554,7 +871,9 @@ export class Server {
|
|
|
554
871
|
const ledgered = this.effectLedgerRuntime?.resolve(capabilities, method, path);
|
|
555
872
|
// A checked response schema needs the handler's VALUE before it becomes bytes, and the fused and
|
|
556
873
|
// native lanes exist precisely to skip that step. So a contracted route leaves them - the same
|
|
557
|
-
// trade an idempotent route makes above
|
|
874
|
+
// structural trade an idempotent route makes above. The check itself is not the cost: a compiled
|
|
875
|
+
// validator measures ~100ns/response, within benchmark noise on any route these lanes already
|
|
876
|
+
// exclude (middleware, derives, lifecycle hooks).
|
|
558
877
|
const runtime = this.responseContractRuntime;
|
|
559
878
|
const contracted = runtime !== undefined && schema?.response !== undefined
|
|
560
879
|
? { runtime, schema: schema.response }
|
|
@@ -589,24 +908,40 @@ export class Server {
|
|
|
589
908
|
this.afterHandleHooks.length === 0 &&
|
|
590
909
|
this.onErrorHooks.length === 0 &&
|
|
591
910
|
this.aroundHooks.length === 0;
|
|
911
|
+
const bodyOnly = contracted === undefined &&
|
|
912
|
+
schema?.body !== undefined &&
|
|
913
|
+
schema.query === undefined &&
|
|
914
|
+
schema.params === undefined &&
|
|
915
|
+
this.derives.length === 0 &&
|
|
916
|
+
this.beforeHandleHooks.length === 0 &&
|
|
917
|
+
this.afterHandleHooks.length === 0 &&
|
|
918
|
+
this.onErrorHooks.length === 0;
|
|
919
|
+
const fusedBody = !bare &&
|
|
920
|
+
bodyOnly &&
|
|
921
|
+
schema.onValidationError === undefined &&
|
|
922
|
+
this.defaultOnValidationError === undefined &&
|
|
923
|
+
idempotent === undefined &&
|
|
924
|
+
ledgered === undefined &&
|
|
925
|
+
this.aroundHooks.length === 0;
|
|
926
|
+
// Body-only routes still need an async body read, but eligible routes can compile the validation +
|
|
927
|
+
// handler continuation once. The runner keeps the bounded parser and all error semantics while
|
|
928
|
+
// avoiding the generic entry/schema/lifecycle dispatch on Bun, Deno, and Node-direct.
|
|
929
|
+
const fusedBodyRunner = fusedBody
|
|
930
|
+
? this.buildFusedBodyRunner(handler, schema.body, hasDecorations ? routeDecorations : undefined)
|
|
931
|
+
: undefined;
|
|
592
932
|
const fusedWeb = bare && this.aroundHooks.length === 0
|
|
593
933
|
? this.buildFusedWeb(handler, hasDecorations ? routeDecorations : undefined, isContextlessNoArgArrow(handler))
|
|
594
934
|
: fusedQuery
|
|
595
935
|
? this.buildFusedQueryWeb(handler, hasDecorations ? routeDecorations : undefined, schema.query)
|
|
596
|
-
:
|
|
936
|
+
: fusedBody
|
|
937
|
+
? this.buildFusedBodyWeb(fusedBodyRunner)
|
|
938
|
+
: undefined;
|
|
597
939
|
const contextless = bare && this.aroundHooks.length === 0 && isContextlessNoArgArrow(handler);
|
|
598
940
|
// The body and query lanes finalize the handler's result themselves, so a contracted route takes
|
|
599
941
|
// the general lifecycle lane instead - one place owns the check rather than three.
|
|
600
942
|
const lane = bare
|
|
601
943
|
? "bare"
|
|
602
|
-
:
|
|
603
|
-
schema?.body !== undefined &&
|
|
604
|
-
schema.query === undefined &&
|
|
605
|
-
schema.params === undefined &&
|
|
606
|
-
this.derives.length === 0 &&
|
|
607
|
-
this.beforeHandleHooks.length === 0 &&
|
|
608
|
-
this.afterHandleHooks.length === 0 &&
|
|
609
|
-
this.onErrorHooks.length === 0
|
|
944
|
+
: bodyOnly
|
|
610
945
|
? "body"
|
|
611
946
|
: contracted === undefined &&
|
|
612
947
|
schema?.body === undefined &&
|
|
@@ -618,8 +953,34 @@ export class Server {
|
|
|
618
953
|
this.onErrorHooks.length === 0
|
|
619
954
|
? "query"
|
|
620
955
|
: "lifecycle";
|
|
621
|
-
|
|
622
|
-
|
|
956
|
+
// Lifecycle routes with no params schema are the common middleware shape: a derive/before hook
|
|
957
|
+
// plus an optional query or body schema. Select their complete validation stage at registration
|
|
958
|
+
// so the request path never re-checks params/body presence. Parameter-schema routes retain the
|
|
959
|
+
// generic lifecycle runner until their more involved recovery matrix is selected explicitly.
|
|
960
|
+
const lifecycleLane = lane !== "lifecycle" || schema?.params !== undefined
|
|
961
|
+
? undefined
|
|
962
|
+
: schema?.body !== undefined
|
|
963
|
+
? schema.query !== undefined
|
|
964
|
+
? "body-query"
|
|
965
|
+
: "body"
|
|
966
|
+
: schema?.query !== undefined
|
|
967
|
+
? "query"
|
|
968
|
+
: "hooks";
|
|
969
|
+
// The realistic middleware shape is commonly exactly one synchronous-or-async derive followed
|
|
970
|
+
// by one before hook. Keep the generic runner for every route that can observe decorations,
|
|
971
|
+
// after hooks, error hooks, or response contracts; this lane only removes the two per-request
|
|
972
|
+
// hook-loop dispatches and preserves the same async continuations and error handling.
|
|
973
|
+
const lifecycleHookLane = lane === "lifecycle" &&
|
|
974
|
+
contracted === undefined &&
|
|
975
|
+
!hasDecorations &&
|
|
976
|
+
this.derives.length === 1 &&
|
|
977
|
+
this.beforeHandleHooks.length === 1 &&
|
|
978
|
+
this.afterHandleHooks.length === 0 &&
|
|
979
|
+
this.onErrorHooks.length === 0
|
|
980
|
+
? "derive-before"
|
|
981
|
+
: undefined;
|
|
982
|
+
const execution = this.compileExecutionPlan(lane, contextless, this.aroundHooks.length > 0, ledgered !== undefined, lifecycleLane, fusedWeb, fusedBodyRunner, fusedWeb === undefined ? undefined : fusedQuery ? "query" : fusedBody ? "body" : "bare");
|
|
983
|
+
const registeredEntry = {
|
|
623
984
|
// (context: never) => unknown -> InternalHandler: the framework invokes it
|
|
624
985
|
// with the concrete RawContext the typed handler expects, so this is sound.
|
|
625
986
|
handler: handler,
|
|
@@ -633,6 +994,7 @@ export class Server {
|
|
|
633
994
|
beforeHandle: [...this.beforeHandleHooks],
|
|
634
995
|
afterHandle: [...this.afterHandleHooks],
|
|
635
996
|
onError: [...this.onErrorHooks],
|
|
997
|
+
lifecycleHookLane,
|
|
636
998
|
around: [...this.aroundHooks],
|
|
637
999
|
execution,
|
|
638
1000
|
};
|
|
@@ -673,17 +1035,17 @@ export class Server {
|
|
|
673
1035
|
method,
|
|
674
1036
|
path,
|
|
675
1037
|
pattern,
|
|
676
|
-
entry,
|
|
1038
|
+
entry: registeredEntry,
|
|
677
1039
|
descriptor,
|
|
678
1040
|
assurance: Object.freeze(routeAssurance),
|
|
679
1041
|
};
|
|
680
1042
|
}
|
|
681
1043
|
/** Collapse route-invariant lifecycle decisions into one runner at registration. The request path
|
|
682
1044
|
* performs no eligibility ladder: it supplies request state to this already-selected plan. */
|
|
683
|
-
compileExecutionPlan(lane, contextless, hasAround, hasLedger, fusedWeb, fusedLane) {
|
|
1045
|
+
compileExecutionPlan(lane, contextless, hasAround, hasLedger, lifecycleLane, fusedWeb, fusedBody, fusedLane) {
|
|
684
1046
|
if (contextless) {
|
|
685
|
-
const run = (runtime, entry, source, params, search, signal, budget, platform, finalize, wrapResponse) => runtime.runContextlessBare(entry, source, params, search, signal, budget, platform, finalize, wrapResponse);
|
|
686
|
-
return Object.freeze({ run, fusedWeb, fusedLane });
|
|
1047
|
+
const run = (runtime, entry, source, params, search, signal, budget, platform, _nativeContext, finalize, wrapResponse) => runtime.runContextlessBare(entry, source, params, search, signal, budget, platform, finalize, wrapResponse);
|
|
1048
|
+
return Object.freeze({ run, fusedWeb, fusedBody, fusedLane });
|
|
687
1049
|
}
|
|
688
1050
|
let inner;
|
|
689
1051
|
switch (lane) {
|
|
@@ -697,13 +1059,30 @@ export class Server {
|
|
|
697
1059
|
inner = (runtime, entry, _source, ctx, finalize, wrapResponse) => runtime.runQueryOnly(entry, ctx, finalize, wrapResponse);
|
|
698
1060
|
break;
|
|
699
1061
|
default:
|
|
700
|
-
|
|
1062
|
+
switch (lifecycleLane) {
|
|
1063
|
+
case "hooks":
|
|
1064
|
+
inner = (runtime, entry, _source, ctx, finalize, wrapResponse) => runtime.runLifecycleHooks(entry, ctx, finalize, wrapResponse);
|
|
1065
|
+
break;
|
|
1066
|
+
case "query":
|
|
1067
|
+
inner = (runtime, entry, _source, ctx, finalize, wrapResponse) => runtime.runLifecycleQuery(entry, ctx, finalize, wrapResponse);
|
|
1068
|
+
break;
|
|
1069
|
+
case "body":
|
|
1070
|
+
inner = (runtime, entry, source, ctx, finalize, wrapResponse) => runtime.runLifecycleBody(entry, source, ctx, finalize, wrapResponse);
|
|
1071
|
+
break;
|
|
1072
|
+
case "body-query":
|
|
1073
|
+
inner = (runtime, entry, source, ctx, finalize, wrapResponse) => runtime.runLifecycleBodyQuery(entry, source, ctx, finalize, wrapResponse);
|
|
1074
|
+
break;
|
|
1075
|
+
default:
|
|
1076
|
+
inner = (runtime, entry, source, ctx, finalize, wrapResponse) => runtime.runLifecycle(entry, source, ctx, finalize, wrapResponse);
|
|
1077
|
+
}
|
|
701
1078
|
}
|
|
702
1079
|
const execute = hasAround
|
|
703
1080
|
? (runtime, entry, source, ctx, finalize, wrapResponse) => runtime.runWithAround(entry, ctx, () => inner(runtime, entry, source, ctx, finalize, wrapResponse), finalize, wrapResponse)
|
|
704
1081
|
: inner;
|
|
705
|
-
const run = (runtime, entry, source, params, search, signal, budget, platform, finalize, wrapResponse) => {
|
|
706
|
-
const ctx =
|
|
1082
|
+
const run = (runtime, entry, source, params, search, signal, budget, platform, nativeContext, finalize, wrapResponse) => {
|
|
1083
|
+
const ctx = nativeContext
|
|
1084
|
+
? RequestContext.native(source, params, search, runtime.maxBodyBytes, platform)
|
|
1085
|
+
: new RequestContext(source, params, search, signal, budget, platform, runtime.maxBodyBytes);
|
|
707
1086
|
let ledger;
|
|
708
1087
|
// The runtime is always present when a route resolved a ledger (enforced at registration).
|
|
709
1088
|
const ledgerRuntime = runtime.effectLedgerRuntime;
|
|
@@ -720,12 +1099,12 @@ export class Server {
|
|
|
720
1099
|
}
|
|
721
1100
|
return outcome;
|
|
722
1101
|
};
|
|
723
|
-
return Object.freeze({ run, fusedWeb, fusedLane });
|
|
1102
|
+
return Object.freeze({ run, fusedWeb, fusedBody, fusedLane });
|
|
724
1103
|
}
|
|
725
1104
|
/** The idempotency lane's bridge back into the normal matched lanes, resolved to a concrete Response
|
|
726
1105
|
* (the lane buffers the body, then replays it through the route's real validation + handler). */
|
|
727
1106
|
idempotencyRunLanes(buffered, platform, entry, params, search) {
|
|
728
|
-
return Promise.resolve(this.runMatchedLanes(buffered, platform, entry, params, search,
|
|
1107
|
+
return Promise.resolve(this.runMatchedLanes(buffered, platform, entry, params, search, this.finalizeResponse, this.wrapWebResponse, this.webResponseTimeout, false));
|
|
729
1108
|
}
|
|
730
1109
|
/** @internal Symbol-keyed install seam for the `responseContract()` plugin. Off the public typed surface. */
|
|
731
1110
|
[INSTALL_RESPONSE_CONTRACT](runtime) {
|
|
@@ -796,8 +1175,23 @@ export class Server {
|
|
|
796
1175
|
this.sseRuntime ??= source.sseRuntime;
|
|
797
1176
|
this.wsRuntime ??= source.wsRuntime;
|
|
798
1177
|
this.onRequestHooks.push(...source.onRequestHooks);
|
|
1178
|
+
this.onNodeRequestHooks.push(...source.onNodeRequestHooks);
|
|
1179
|
+
this.nodeRequestHooksComplete &&= source.nodeRequestHooksComplete;
|
|
1180
|
+
// The group's static declarations came before its own response hooks, so they are folded in
|
|
1181
|
+
// first - and fold themselves into a hook here if this server already has one (same ordering
|
|
1182
|
+
// rule as a direct `responseHeaders()` call).
|
|
1183
|
+
if (source.staticResponseHeaders !== undefined) {
|
|
1184
|
+
this.addStaticResponseHeaders({ ...source.staticResponseHeaders.record });
|
|
1185
|
+
}
|
|
799
1186
|
this.onResponseHooks.push(...source.onResponseHooks);
|
|
1187
|
+
this.onNodeResponseHooks.push(...source.onNodeResponseHooks);
|
|
1188
|
+
this.nodeResponseHooksComplete &&= source.nodeResponseHooksComplete;
|
|
800
1189
|
this.onResponseFinalizedHooks.push(...source.onResponseFinalizedHooks);
|
|
1190
|
+
if (source.responseBodyTag !== undefined) {
|
|
1191
|
+
const owner = this.enableResponseBodyTagging();
|
|
1192
|
+
this.responseBodyOwners.add(source.responseBodyTag);
|
|
1193
|
+
source.responseBodyOwners.add(owner);
|
|
1194
|
+
}
|
|
801
1195
|
this.globalAssurance.push(...source.globalAssurance);
|
|
802
1196
|
this.mcpResourceList.push(...source.mcpResourceList);
|
|
803
1197
|
this.mcpPromptList.push(...source.mcpPromptList);
|
|
@@ -811,14 +1205,19 @@ export class Server {
|
|
|
811
1205
|
return route;
|
|
812
1206
|
// Rebuild with the SAME builder that produced the closure - rebinding a query-fused route as
|
|
813
1207
|
// bare would silently drop its validation.
|
|
814
|
-
const
|
|
815
|
-
? this.
|
|
816
|
-
:
|
|
1208
|
+
const fusedBody = entry.execution.fusedLane === "body"
|
|
1209
|
+
? this.buildFusedBodyRunner(entry.handler, entry.schema?.body, entry.hasDecorations ? entry.decorations : undefined)
|
|
1210
|
+
: undefined;
|
|
1211
|
+
const fusedWeb = entry.execution.fusedLane === "body"
|
|
1212
|
+
? this.buildFusedBodyWeb(fusedBody)
|
|
1213
|
+
: entry.execution.fusedLane === "query"
|
|
1214
|
+
? this.buildFusedQueryWeb(entry.handler, entry.hasDecorations ? entry.decorations : undefined, entry.schema?.query)
|
|
1215
|
+
: this.buildFusedWeb(entry.handler, entry.hasDecorations ? entry.decorations : undefined, isContextlessNoArgArrow(entry.handler));
|
|
817
1216
|
return {
|
|
818
1217
|
...route,
|
|
819
1218
|
entry: {
|
|
820
1219
|
...entry,
|
|
821
|
-
execution: Object.freeze({ ...entry.execution, fusedWeb }),
|
|
1220
|
+
execution: Object.freeze({ ...entry.execution, fusedWeb, fusedBody }),
|
|
822
1221
|
},
|
|
823
1222
|
};
|
|
824
1223
|
}
|
|
@@ -859,7 +1258,7 @@ export class Server {
|
|
|
859
1258
|
// path, selected by the compiled execution plan), and an `async fetch` would wrap every such result in a redundant
|
|
860
1259
|
// promise + microtask. Returning `Response | Promise<Response>` matches Web/edge handlers, while
|
|
861
1260
|
// `await app.fetch(...)` continues to work exactly as before.
|
|
862
|
-
const outcome = this.dispatch(source, platform,
|
|
1261
|
+
const outcome = this.dispatch(source, platform, this.finalizeResponse, this.wrapWebResponse, this.webResponseTimeout, true);
|
|
863
1262
|
if (this.onResponseHooks.length === 0 && this.onResponseFinalizedHooks.length === 0) {
|
|
864
1263
|
return outcome;
|
|
865
1264
|
}
|
|
@@ -877,7 +1276,7 @@ export class Server {
|
|
|
877
1276
|
return this.admitGated(requestOf(source), () => this.fetchMatchedInner(source, entry, params));
|
|
878
1277
|
}
|
|
879
1278
|
fetchMatchedInner(source, entry, params) {
|
|
880
|
-
const outcome = this.runMatched(source, undefined, entry, params, undefined,
|
|
1279
|
+
const outcome = this.runMatched(source, undefined, entry, params, undefined, this.finalizeResponse, this.wrapWebResponse, this.webResponseTimeout, true);
|
|
881
1280
|
if (this.onResponseHooks.length === 0 && this.onResponseFinalizedHooks.length === 0) {
|
|
882
1281
|
return outcome;
|
|
883
1282
|
}
|
|
@@ -927,20 +1326,28 @@ export class Server {
|
|
|
927
1326
|
* Like {@link fetch}, but renders a plain-data result **without** building a Web `Response` - the
|
|
928
1327
|
* `@nifrajs/node` adapter serializes the returned primitives straight to the socket, skipping the undici
|
|
929
1328
|
* `Response` build + body drain (the bulk of the Node bridge cost, measured ≈4µs/req). A handler that
|
|
930
|
-
* returns a `Response`, an error/short-circuit, or
|
|
931
|
-
* full Web path
|
|
932
|
-
*
|
|
933
|
-
* render differs.
|
|
1329
|
+
* returns a `Response`, an error/short-circuit, or a response hook that replaces/consumes the buffered
|
|
1330
|
+
* body stays on the full Web path; an in-place response hook can still use the direct writer. Same
|
|
1331
|
+
* lifecycle as {@link fetch} (body cap, validation, hooks all run); only the final render differs.
|
|
934
1332
|
*/
|
|
935
1333
|
resolveNode(req, platform) {
|
|
936
1334
|
return this.resolveNodeSource(req, platform);
|
|
937
1335
|
}
|
|
938
1336
|
resolveNodeSource(source, platform, suppliedRuntime) {
|
|
939
|
-
//
|
|
940
|
-
//
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
1337
|
+
// A paired header-only native hook can preserve the Node-direct JSON/body outcome for successful
|
|
1338
|
+
// responses. Arbitrary onResponse transforms need a real Web Response, but a buffered outcome can
|
|
1339
|
+
// be materialized with a direct-write marker and return to the socket path when the hook mutates
|
|
1340
|
+
// it in place. Finalization observers and the capacity gate still wrap the complete Web path.
|
|
1341
|
+
// The native response lane engages only when the REQUEST side is native too (or there are no
|
|
1342
|
+
// request hooks at all). This is what makes the NodeRequestContext identity contract hold: a
|
|
1343
|
+
// web request-hook walk can rewrite the request, so its response-side view is a synthetic
|
|
1344
|
+
// wrapper - a different object - and any middleware carrying per-request state from its request
|
|
1345
|
+
// twin to its response twin through a WeakMap would silently miss. Coupling the gates means a
|
|
1346
|
+
// response twin always sees the exact object its request twin saw.
|
|
1347
|
+
const nativeResponseHooks = this.canUseNodeResponseHooks() &&
|
|
1348
|
+
(this.onRequestHooks.length === 0 || this.canUseNodeRequestHooks());
|
|
1349
|
+
const webResponseHooks = this.onResponseHooks.length > 0 && !nativeResponseHooks;
|
|
1350
|
+
if (this.onResponseFinalizedHooks.length > 0 || this.capacityGate !== undefined) {
|
|
944
1351
|
const response = this.fetchSource(source, platform);
|
|
945
1352
|
return response instanceof Promise
|
|
946
1353
|
? response.then((settled) => ({ kind: "response", response: settled }))
|
|
@@ -953,7 +1360,151 @@ export class Server {
|
|
|
953
1360
|
if (runtime === undefined) {
|
|
954
1361
|
throw new FrameworkError("NODE_DIRECT_RUNTIME_MISSING", "resolveNode() needs the Node-direct renderer. Normal @nifrajs/node serving installs it automatically; direct callers should add `.use(nodeDirect())` (from `@nifrajs/core/node-direct`).");
|
|
955
1362
|
}
|
|
956
|
-
|
|
1363
|
+
const resolved = this.dispatch(source, platform, runtime.toOutcome, runtime.fromResponse, runtime.timeout, false);
|
|
1364
|
+
// Fold declared static headers into the record ONCE, here: before any native twin runs (so a
|
|
1365
|
+
// header or body hook reads them through its view), and on the no-hook path too (which returns
|
|
1366
|
+
// the outcome straight to the writer without the finish step below).
|
|
1367
|
+
const statics = this.staticResponseHeaders;
|
|
1368
|
+
const outcome = statics === undefined
|
|
1369
|
+
? resolved
|
|
1370
|
+
: resolved instanceof Promise
|
|
1371
|
+
? resolved.then((settled) => withStaticNodeHeaders(settled, statics))
|
|
1372
|
+
: withStaticNodeHeaders(resolved, statics);
|
|
1373
|
+
if (webResponseHooks) {
|
|
1374
|
+
return outcome instanceof Promise
|
|
1375
|
+
? outcome.then((settled) => this.finishNodeWebResponse(settled, source, runtime))
|
|
1376
|
+
: this.finishNodeWebResponse(outcome, source, runtime);
|
|
1377
|
+
}
|
|
1378
|
+
if (!nativeResponseHooks)
|
|
1379
|
+
return outcome;
|
|
1380
|
+
try {
|
|
1381
|
+
return outcome instanceof Promise
|
|
1382
|
+
? outcome.then((settled) => this.finishNodeResponse(settled, source, runtime))
|
|
1383
|
+
: this.finishNodeResponse(outcome, source, runtime);
|
|
1384
|
+
}
|
|
1385
|
+
catch (error) {
|
|
1386
|
+
// Keep resolveNode's failure shape promise-based, matching app.fetch and the adapter bridge.
|
|
1387
|
+
return Promise.reject(error);
|
|
1388
|
+
}
|
|
1389
|
+
}
|
|
1390
|
+
/** Run generic Web response middleware while retaining direct writes for untouched buffered bodies. */
|
|
1391
|
+
finishNodeWebResponse(outcome, source, runtime) {
|
|
1392
|
+
const req = this.takeResponseRequest(source);
|
|
1393
|
+
const response = runtime.toResponse(outcome);
|
|
1394
|
+
const transformed = this.applyOnResponseAndFinalize(response, req);
|
|
1395
|
+
return transformed instanceof Promise
|
|
1396
|
+
? transformed.then(runtime.fromResponse)
|
|
1397
|
+
: runtime.fromResponse(transformed);
|
|
1398
|
+
}
|
|
1399
|
+
/** True only when every transforming Web response hook has a header-only Node equivalent. */
|
|
1400
|
+
canUseNodeResponseHooks() {
|
|
1401
|
+
return this.onResponseHooks.length > 0 && this.nodeResponseHooksComplete;
|
|
1402
|
+
}
|
|
1403
|
+
/** Apply paired native hooks to data outcomes; preserve the complete Web hook pipeline for Response outcomes. */
|
|
1404
|
+
finishNodeResponse(outcome, source, runtime) {
|
|
1405
|
+
if (outcome.kind === "response") {
|
|
1406
|
+
const req = this.takeResponseRequest(source);
|
|
1407
|
+
const transformed = this.applyOnResponseAndFinalize(outcome.response, req);
|
|
1408
|
+
return transformed instanceof Promise
|
|
1409
|
+
? transformed.then(runtime.fromResponse)
|
|
1410
|
+
: runtime.fromResponse(transformed);
|
|
1411
|
+
}
|
|
1412
|
+
let headers = outcome.headers;
|
|
1413
|
+
if (outcome.kind === "json" && outcome.body !== null) {
|
|
1414
|
+
// The json render adds its Content-Type at WRITE time, so a body hook checking content types
|
|
1415
|
+
// would see nothing. Materialize the writer's own value into the hook-visible record - same
|
|
1416
|
+
// string the writer would emit, so the wire is unchanged.
|
|
1417
|
+
const hasType = headers !== undefined &&
|
|
1418
|
+
Object.keys(headers).some((key) => key.toLowerCase() === "content-type");
|
|
1419
|
+
if (!hasType) {
|
|
1420
|
+
const defaultContentType = runtime.jsonContentType ?? "application/json;charset=utf-8";
|
|
1421
|
+
if (headers === undefined) {
|
|
1422
|
+
headers = { "content-type": defaultContentType };
|
|
1423
|
+
}
|
|
1424
|
+
else {
|
|
1425
|
+
// The record belongs to this outcome. Add the implicit JSON type in place so the native
|
|
1426
|
+
// header walk does not clone every c.set.headers record before it can run. A hook that
|
|
1427
|
+
// replaces or deletes the type still takes the existing withNodeResponseHeaders path;
|
|
1428
|
+
// the writer's final defaulting behavior remains unchanged.
|
|
1429
|
+
headers["content-type"] = defaultContentType;
|
|
1430
|
+
}
|
|
1431
|
+
}
|
|
1432
|
+
}
|
|
1433
|
+
const context = {
|
|
1434
|
+
status: outcome.status,
|
|
1435
|
+
headers,
|
|
1436
|
+
cookies: outcome.kind === "json" ? outcome.cookies : undefined,
|
|
1437
|
+
body: outcome.body,
|
|
1438
|
+
};
|
|
1439
|
+
const applied = this.applyNodeResponseHooks(context, this.takeNodeResponseRequest(source));
|
|
1440
|
+
if (applied instanceof Promise) {
|
|
1441
|
+
return applied.then(() => this.withNodeResponseHeaders(outcome, context));
|
|
1442
|
+
}
|
|
1443
|
+
return this.withNodeResponseHeaders(outcome, context);
|
|
1444
|
+
}
|
|
1445
|
+
withNodeResponseHeaders(outcome, context) {
|
|
1446
|
+
const outcomeCookies = outcome.kind === "json" ? outcome.cookies : undefined;
|
|
1447
|
+
const bodyChanged = context.body !== outcome.body;
|
|
1448
|
+
const statusChanged = context.status !== outcome.status;
|
|
1449
|
+
if (context.headers === outcome.headers &&
|
|
1450
|
+
context.cookies === outcomeCookies &&
|
|
1451
|
+
!bodyChanged &&
|
|
1452
|
+
!statusChanged) {
|
|
1453
|
+
return outcome;
|
|
1454
|
+
}
|
|
1455
|
+
let headers = context.headers;
|
|
1456
|
+
if (bodyChanged && headers !== undefined) {
|
|
1457
|
+
// A replaced body invalidates any explicitly carried length; the writers re-derive framing
|
|
1458
|
+
// from the final bytes.
|
|
1459
|
+
const stale = Object.keys(headers).find((key) => key.toLowerCase() === "content-length");
|
|
1460
|
+
if (stale !== undefined) {
|
|
1461
|
+
headers = { ...headers };
|
|
1462
|
+
delete headers[stale];
|
|
1463
|
+
}
|
|
1464
|
+
}
|
|
1465
|
+
if (outcome.kind === "json") {
|
|
1466
|
+
if (bodyChanged && context.body !== null && typeof context.body !== "string") {
|
|
1467
|
+
// A binary replacement can't ride the json render - switch to the buffered-body render,
|
|
1468
|
+
// folding queued cookies into explicit set-cookie lines so nothing is dropped.
|
|
1469
|
+
const record = Object.create(null);
|
|
1470
|
+
if (headers !== undefined)
|
|
1471
|
+
Object.assign(record, headers);
|
|
1472
|
+
if (context.cookies !== undefined && context.cookies.length > 0) {
|
|
1473
|
+
record["set-cookie"] = [...context.cookies];
|
|
1474
|
+
}
|
|
1475
|
+
return { kind: "body", status: context.status, headers: record, body: context.body };
|
|
1476
|
+
}
|
|
1477
|
+
return {
|
|
1478
|
+
...outcome,
|
|
1479
|
+
status: context.status,
|
|
1480
|
+
headers,
|
|
1481
|
+
cookies: context.cookies,
|
|
1482
|
+
body: bodyChanged ? context.body : outcome.body,
|
|
1483
|
+
};
|
|
1484
|
+
}
|
|
1485
|
+
return {
|
|
1486
|
+
...outcome,
|
|
1487
|
+
status: context.status,
|
|
1488
|
+
headers,
|
|
1489
|
+
body: bodyChanged
|
|
1490
|
+
? (context.body ?? new Uint8Array(0))
|
|
1491
|
+
: outcome.body,
|
|
1492
|
+
};
|
|
1493
|
+
}
|
|
1494
|
+
/** Synchronous until a native response hook actually returns a Promise. */
|
|
1495
|
+
applyNodeResponseHooks(response, req) {
|
|
1496
|
+
for (let i = 0; i < this.onNodeResponseHooks.length; i++) {
|
|
1497
|
+
const hook = this.onNodeResponseHooks[i];
|
|
1498
|
+
const result = hook(response, req);
|
|
1499
|
+
if (result instanceof Promise)
|
|
1500
|
+
return result.then(() => this.continueNodeResponseHooks(i + 1, response, req));
|
|
1501
|
+
}
|
|
1502
|
+
}
|
|
1503
|
+
async continueNodeResponseHooks(start, response, req) {
|
|
1504
|
+
for (let i = start; i < this.onNodeResponseHooks.length; i++) {
|
|
1505
|
+
const hook = this.onNodeResponseHooks[i];
|
|
1506
|
+
await hook(response, req);
|
|
1507
|
+
}
|
|
957
1508
|
}
|
|
958
1509
|
/**
|
|
959
1510
|
* Resolve a WebSocket upgrade - the seam every serving adapter uses. Returns `pass` (not a WS
|
|
@@ -1062,8 +1613,42 @@ export class Server {
|
|
|
1062
1613
|
if (this.onRequestHooks.length === 0) {
|
|
1063
1614
|
return this.routeAndRun(source, resolved, finalize, wrapResponse, onTimeout, webFast);
|
|
1064
1615
|
}
|
|
1616
|
+
if (!webFast && this.canUseNodeRequestHooks()) {
|
|
1617
|
+
return this.runWithNodeRequest(source, resolved, finalize, wrapResponse, onTimeout);
|
|
1618
|
+
}
|
|
1065
1619
|
return this.runWithOnRequest(source, resolved, finalize, wrapResponse, onTimeout, webFast);
|
|
1066
1620
|
}
|
|
1621
|
+
/** Node-native request hook walk. A header-only hook can inspect the lazy source without forcing a
|
|
1622
|
+
* Web `Request`; arbitrary request rewrites and full Web hooks use {@link runWithOnRequest}. */
|
|
1623
|
+
runWithNodeRequest(source, platform, finalize, wrapResponse, onTimeout) {
|
|
1624
|
+
const hooks = this.onNodeRequestHooks;
|
|
1625
|
+
const request = this.nodeRequestContextOf(source);
|
|
1626
|
+
for (let i = 0; i < hooks.length; i++) {
|
|
1627
|
+
const hook = hooks[i];
|
|
1628
|
+
const outcome = hook(request, platform);
|
|
1629
|
+
if (outcome instanceof Promise) {
|
|
1630
|
+
return outcome.then((early) => this.continueNodeRequest(early, i + 1, request, source, platform, finalize, wrapResponse, onTimeout));
|
|
1631
|
+
}
|
|
1632
|
+
if (outcome !== undefined)
|
|
1633
|
+
return wrapResponse(outcome);
|
|
1634
|
+
}
|
|
1635
|
+
return this.routeAndRun(source, platform, finalize, wrapResponse, onTimeout, false);
|
|
1636
|
+
}
|
|
1637
|
+
async continueNodeRequest(first, nextIndex, request, source, platform, finalize, wrapResponse, onTimeout) {
|
|
1638
|
+
if (first !== undefined)
|
|
1639
|
+
return wrapResponse(first);
|
|
1640
|
+
for (let i = nextIndex; i < this.onNodeRequestHooks.length; i++) {
|
|
1641
|
+
const hook = this.onNodeRequestHooks[i];
|
|
1642
|
+
const outcome = hook(request, platform);
|
|
1643
|
+
const early = outcome instanceof Promise ? await outcome : outcome;
|
|
1644
|
+
if (early !== undefined)
|
|
1645
|
+
return wrapResponse(early);
|
|
1646
|
+
}
|
|
1647
|
+
return this.routeAndRun(source, platform, finalize, wrapResponse, onTimeout, false);
|
|
1648
|
+
}
|
|
1649
|
+
canUseNodeRequestHooks() {
|
|
1650
|
+
return this.onRequestHooks.length > 0 && this.nodeRequestHooksComplete;
|
|
1651
|
+
}
|
|
1067
1652
|
/**
|
|
1068
1653
|
* onRequest short-circuit path. Synchronous as long as every hook returns synchronously (the
|
|
1069
1654
|
* common case - e.g. CORS returning `undefined` for a non-preflight request): an `async` version
|
|
@@ -1074,6 +1659,7 @@ export class Server {
|
|
|
1074
1659
|
runWithOnRequest(source, platform, finalize, wrapResponse, onTimeout, webFast) {
|
|
1075
1660
|
const hooks = this.onRequestHooks;
|
|
1076
1661
|
const originalRequest = requestOf(source);
|
|
1662
|
+
this.responseSources.set(source, originalRequest);
|
|
1077
1663
|
let current = source;
|
|
1078
1664
|
for (let i = 0; i < hooks.length; i++) {
|
|
1079
1665
|
const outcome = hooks[i](requestOf(current), platform);
|
|
@@ -1115,6 +1701,16 @@ export class Server {
|
|
|
1115
1701
|
return this.routeAndRun(current, platform, finalize, wrapResponse, onTimeout, webFast);
|
|
1116
1702
|
}
|
|
1117
1703
|
takeResponseRequest(source) {
|
|
1704
|
+
const tracked = this.responseSources.get(source);
|
|
1705
|
+
if (tracked !== undefined) {
|
|
1706
|
+
this.responseSources.delete(source);
|
|
1707
|
+
const rewritten = this.responseRequests.get(tracked);
|
|
1708
|
+
if (rewritten !== undefined) {
|
|
1709
|
+
this.responseRequests.delete(tracked);
|
|
1710
|
+
return rewritten;
|
|
1711
|
+
}
|
|
1712
|
+
return tracked;
|
|
1713
|
+
}
|
|
1118
1714
|
const request = requestOf(source);
|
|
1119
1715
|
const rewritten = this.responseRequests.get(request);
|
|
1120
1716
|
if (rewritten === undefined)
|
|
@@ -1122,14 +1718,99 @@ export class Server {
|
|
|
1122
1718
|
this.responseRequests.delete(request);
|
|
1123
1719
|
return rewritten;
|
|
1124
1720
|
}
|
|
1721
|
+
/**
|
|
1722
|
+
* The NodeRequestContext for a source, MEMOIZED per source so the request twins and the response
|
|
1723
|
+
* twins receive the exact same object within one request - that identity is the documented
|
|
1724
|
+
* contract stateful twins key their WeakMaps on. An adapter source (which already speaks the
|
|
1725
|
+
* interface) is returned as-is; a plain `Request` source (a direct `resolveNode` caller) gets one
|
|
1726
|
+
* cached wrapper.
|
|
1727
|
+
*/
|
|
1728
|
+
nodeRequestContextOf(source) {
|
|
1729
|
+
if (source.header !== undefined)
|
|
1730
|
+
return source;
|
|
1731
|
+
let context = this.nodeContexts.get(source);
|
|
1732
|
+
if (context === undefined) {
|
|
1733
|
+
const request = requestOf(source);
|
|
1734
|
+
context = {
|
|
1735
|
+
method: request.method,
|
|
1736
|
+
url: request.url,
|
|
1737
|
+
header: (name) => request.headers.get(name),
|
|
1738
|
+
};
|
|
1739
|
+
this.nodeContexts.set(source, context);
|
|
1740
|
+
}
|
|
1741
|
+
return context;
|
|
1742
|
+
}
|
|
1743
|
+
/** Preserve the request visible to generic onRequest hooks for paired native response hooks. */
|
|
1744
|
+
takeNodeResponseRequest(source) {
|
|
1745
|
+
const tracked = this.responseSources.get(source);
|
|
1746
|
+
if (tracked !== undefined) {
|
|
1747
|
+
this.responseSources.delete(source);
|
|
1748
|
+
const rewritten = this.responseRequests.get(tracked);
|
|
1749
|
+
if (rewritten !== undefined) {
|
|
1750
|
+
this.responseRequests.delete(tracked);
|
|
1751
|
+
return {
|
|
1752
|
+
method: rewritten.method,
|
|
1753
|
+
url: rewritten.url,
|
|
1754
|
+
header: (name) => rewritten.headers.get(name),
|
|
1755
|
+
};
|
|
1756
|
+
}
|
|
1757
|
+
return {
|
|
1758
|
+
method: tracked.method,
|
|
1759
|
+
url: tracked.url,
|
|
1760
|
+
header: (name) => tracked.headers.get(name),
|
|
1761
|
+
};
|
|
1762
|
+
}
|
|
1763
|
+
return this.nodeRequestContextOf(source);
|
|
1764
|
+
}
|
|
1125
1765
|
/**
|
|
1126
1766
|
* Route → build context → run. Synchronous through to the handler for a **bare** route
|
|
1127
1767
|
* (selected by its compiled plan), so a sync handler produces its result with zero promise allocations;
|
|
1128
1768
|
* routes with validation/hooks keep the full async {@link runLifecycle}, unchanged.
|
|
1129
1769
|
*/
|
|
1130
1770
|
routeAndRun(source, platform, finalize, wrapResponse, onTimeout, webFast) {
|
|
1131
|
-
|
|
1132
|
-
|
|
1771
|
+
// Routing only needs the pathname. Scan the URL once but keep the two slices in locals instead
|
|
1772
|
+
// of allocating the `{ pathname, search }` pair returned by the public helper on every request.
|
|
1773
|
+
let pathname;
|
|
1774
|
+
let search;
|
|
1775
|
+
// Read the source's split ONCE. `urlParts` is an accessor on the Node request sources that
|
|
1776
|
+
// rescans the target and allocates a fresh pair every time it is touched, so testing it and then
|
|
1777
|
+
// reading each half through the accessor scanned the URL three times per request and threw two
|
|
1778
|
+
// of the three pairs away immediately.
|
|
1779
|
+
const parts = source.urlParts;
|
|
1780
|
+
if (parts !== undefined) {
|
|
1781
|
+
pathname = parts.pathname;
|
|
1782
|
+
search = parts.search;
|
|
1783
|
+
}
|
|
1784
|
+
else {
|
|
1785
|
+
const rawUrl = source.url;
|
|
1786
|
+
const schemeEnd = rawUrl.indexOf("://");
|
|
1787
|
+
const start = schemeEnd === -1 ? rawUrl.indexOf("/") : rawUrl.indexOf("/", schemeEnd + 3);
|
|
1788
|
+
if (start === -1) {
|
|
1789
|
+
pathname = "/";
|
|
1790
|
+
search = "";
|
|
1791
|
+
}
|
|
1792
|
+
else {
|
|
1793
|
+
let pathEnd = rawUrl.length;
|
|
1794
|
+
let searchStart = -1;
|
|
1795
|
+
let searchEnd = rawUrl.length;
|
|
1796
|
+
for (let i = start; i < rawUrl.length; i++) {
|
|
1797
|
+
const c = rawUrl.charCodeAt(i);
|
|
1798
|
+
if (c === 63 /* ? */ && searchStart === -1) {
|
|
1799
|
+
pathEnd = i;
|
|
1800
|
+
searchStart = i;
|
|
1801
|
+
}
|
|
1802
|
+
else if (c === 35 /* # */) {
|
|
1803
|
+
if (searchStart === -1)
|
|
1804
|
+
pathEnd = i;
|
|
1805
|
+
searchEnd = i;
|
|
1806
|
+
break;
|
|
1807
|
+
}
|
|
1808
|
+
}
|
|
1809
|
+
pathname = rawUrl.slice(start, pathEnd);
|
|
1810
|
+
search = searchStart === -1 ? "" : rawUrl.slice(searchStart, searchEnd);
|
|
1811
|
+
}
|
|
1812
|
+
}
|
|
1813
|
+
const match = this.catalog.find(source.method, pathname);
|
|
1133
1814
|
if (!match.found) {
|
|
1134
1815
|
if (match.reason === "method-not-allowed") {
|
|
1135
1816
|
return wrapResponse(jsonError(405, "method_not_allowed", { Allow: match.allowed.join(", ") }));
|
|
@@ -1142,7 +1823,7 @@ export class Server {
|
|
|
1142
1823
|
if (params === null) {
|
|
1143
1824
|
return wrapResponse(jsonError(400, "malformed_path"));
|
|
1144
1825
|
}
|
|
1145
|
-
return this.runMatched(source, platform, match.payload, params,
|
|
1826
|
+
return this.runMatched(source, platform, match.payload, params, search, finalize, wrapResponse, onTimeout, webFast);
|
|
1146
1827
|
}
|
|
1147
1828
|
/** Run a route that has already been matched by the runtime or Nifra's portable router. */
|
|
1148
1829
|
runMatched(source, platform, entry, params, search, finalize, wrapResponse, onTimeout, webFast) {
|
|
@@ -1192,9 +1873,12 @@ export class Server {
|
|
|
1192
1873
|
? getUnboundedRequestBudget()
|
|
1193
1874
|
: createRequestBudget({ deadline: admission.deadline, signal });
|
|
1194
1875
|
const plan = entry.execution;
|
|
1876
|
+
const nativeContext = controller === undefined;
|
|
1195
1877
|
const outcome = webFast && plan.fusedWeb !== undefined
|
|
1196
|
-
? plan.fusedWeb(source, params, search, signal, budget, platform,
|
|
1197
|
-
: plan.
|
|
1878
|
+
? plan.fusedWeb(source, params, search, signal, budget, platform, nativeContext)
|
|
1879
|
+
: !webFast && plan.fusedBody !== undefined
|
|
1880
|
+
? plan.fusedBody(source, params, search, signal, budget, platform, false, finalize, wrapResponse)
|
|
1881
|
+
: plan.run(this, entry, source, params, search, signal, budget, platform, nativeContext, finalize, wrapResponse);
|
|
1198
1882
|
// The request timeout only bounds work that is actually pending - a synchronous (bare) result is
|
|
1199
1883
|
// already complete and can't time out, so it's returned as-is (no 503 race, no promise).
|
|
1200
1884
|
if (controller !== undefined && outcome instanceof Promise) {
|
|
@@ -1266,11 +1950,13 @@ export class Server {
|
|
|
1266
1950
|
* 500s), same respond semantics (the lifecycle parity suite pins it).
|
|
1267
1951
|
*/
|
|
1268
1952
|
buildFusedWeb(handler, decorations, contextless) {
|
|
1953
|
+
// The fused lanes return to the runtime directly, so the framework's own error renders fold in
|
|
1954
|
+
// static headers here rather than through the shared `wrapResponse` seam.
|
|
1269
1955
|
const logError = (err, ctx) => {
|
|
1270
1956
|
if (err instanceof Response)
|
|
1271
|
-
return err;
|
|
1957
|
+
return this.wrapWebResponse(err);
|
|
1272
1958
|
this.logRequestError(err, ctx);
|
|
1273
|
-
return jsonError(500, "internal_error");
|
|
1959
|
+
return this.wrapWebResponse(jsonError(500, "internal_error"));
|
|
1274
1960
|
};
|
|
1275
1961
|
if (contextless && decorations === undefined) {
|
|
1276
1962
|
// `() => ...` can't observe the context - skip allocating one entirely (errors still build
|
|
@@ -1283,20 +1969,20 @@ export class Server {
|
|
|
1283
1969
|
}
|
|
1284
1970
|
catch (err) {
|
|
1285
1971
|
return logError(err, nativeContext
|
|
1286
|
-
? RequestContext.native(source, params, this.maxBodyBytes)
|
|
1972
|
+
? RequestContext.native(source, params, search, this.maxBodyBytes, platform)
|
|
1287
1973
|
: new RequestContext(source, params, search, signal, budget, platform, this.maxBodyBytes));
|
|
1288
1974
|
}
|
|
1289
1975
|
if (result instanceof Promise) {
|
|
1290
|
-
return result.then(fusedRespondNoSet, (err) => logError(err, nativeContext
|
|
1291
|
-
? RequestContext.native(source, params, this.maxBodyBytes)
|
|
1976
|
+
return result.then((value) => fusedRespondNoSet(value, this.responseBodyTag, this.staticResponseHeaders), (err) => logError(err, nativeContext
|
|
1977
|
+
? RequestContext.native(source, params, search, this.maxBodyBytes, platform)
|
|
1292
1978
|
: new RequestContext(source, params, search, signal, budget, platform, this.maxBodyBytes)));
|
|
1293
1979
|
}
|
|
1294
|
-
return fusedRespondNoSet(result);
|
|
1980
|
+
return fusedRespondNoSet(result, this.responseBodyTag, this.staticResponseHeaders);
|
|
1295
1981
|
};
|
|
1296
1982
|
}
|
|
1297
1983
|
return (source, params, search, signal, budget, platform, nativeContext) => {
|
|
1298
1984
|
const ctx = nativeContext
|
|
1299
|
-
? RequestContext.native(source, params, this.maxBodyBytes)
|
|
1985
|
+
? RequestContext.native(source, params, search, this.maxBodyBytes, platform)
|
|
1300
1986
|
: new RequestContext(source, params, search, signal, budget, platform, this.maxBodyBytes);
|
|
1301
1987
|
if (decorations !== undefined)
|
|
1302
1988
|
Object.assign(ctx, decorations);
|
|
@@ -1308,11 +1994,87 @@ export class Server {
|
|
|
1308
1994
|
return logError(err, ctx);
|
|
1309
1995
|
}
|
|
1310
1996
|
if (result instanceof Promise) {
|
|
1311
|
-
return result.then((value) => fusedRespond(value, ctx), (err) => logError(err, ctx));
|
|
1997
|
+
return result.then((value) => fusedRespond(value, ctx, this.responseBodyTag, this.staticResponseHeaders), (err) => logError(err, ctx));
|
|
1998
|
+
}
|
|
1999
|
+
return fusedRespond(result, ctx, this.responseBodyTag, this.staticResponseHeaders);
|
|
2000
|
+
};
|
|
2001
|
+
}
|
|
2002
|
+
/** Compile the eligible body-only route's parser → validator → handler continuation once. The
|
|
2003
|
+
* bounded parser remains shared with the generic lane; only the route-invariant entry lookups and
|
|
2004
|
+
* lifecycle dispatch disappear from the common synchronous-validator/synchronous-handler case. */
|
|
2005
|
+
buildFusedBodyRunner(handler, bodySchema, decorations) {
|
|
2006
|
+
const logError = (err, ctx, wrapResponse) => this.bareError(err, ctx, wrapResponse);
|
|
2007
|
+
const runHandler = (ctx, finalize, wrapResponse) => {
|
|
2008
|
+
if (decorations !== undefined)
|
|
2009
|
+
Object.assign(ctx, decorations);
|
|
2010
|
+
let output;
|
|
2011
|
+
try {
|
|
2012
|
+
output = handler(ctx);
|
|
2013
|
+
}
|
|
2014
|
+
catch (err) {
|
|
2015
|
+
return logError(err, ctx, wrapResponse);
|
|
2016
|
+
}
|
|
2017
|
+
if (output instanceof Promise) {
|
|
2018
|
+
return output.then((result) => {
|
|
2019
|
+
try {
|
|
2020
|
+
return finalize(result, responseSet(ctx), ctx);
|
|
2021
|
+
}
|
|
2022
|
+
catch (err) {
|
|
2023
|
+
return logError(err, ctx, wrapResponse);
|
|
2024
|
+
}
|
|
2025
|
+
}, (err) => logError(err, ctx, wrapResponse));
|
|
2026
|
+
}
|
|
2027
|
+
try {
|
|
2028
|
+
return finalize(output, responseSet(ctx), ctx);
|
|
2029
|
+
}
|
|
2030
|
+
catch (err) {
|
|
2031
|
+
return logError(err, ctx, wrapResponse);
|
|
2032
|
+
}
|
|
2033
|
+
};
|
|
2034
|
+
const runValidated = (result, ctx, finalize, wrapResponse) => {
|
|
2035
|
+
if (result.issues !== undefined)
|
|
2036
|
+
return wrapResponse(validationError(result.issues));
|
|
2037
|
+
ctx.body = result.value;
|
|
2038
|
+
return runHandler(ctx, finalize, wrapResponse);
|
|
2039
|
+
};
|
|
2040
|
+
const runParsed = (parsed, ctx, finalize, wrapResponse) => {
|
|
2041
|
+
let validation;
|
|
2042
|
+
try {
|
|
2043
|
+
validation = bodySchema["~standard"].validate(parsed);
|
|
2044
|
+
}
|
|
2045
|
+
catch (err) {
|
|
2046
|
+
return logError(err, ctx, wrapResponse);
|
|
2047
|
+
}
|
|
2048
|
+
if (validation instanceof Promise) {
|
|
2049
|
+
return validation.then((settled) => {
|
|
2050
|
+
try {
|
|
2051
|
+
return runValidated(settled, ctx, finalize, wrapResponse);
|
|
2052
|
+
}
|
|
2053
|
+
catch (err) {
|
|
2054
|
+
return logError(err, ctx, wrapResponse);
|
|
2055
|
+
}
|
|
2056
|
+
}, (err) => logError(err, ctx, wrapResponse));
|
|
2057
|
+
}
|
|
2058
|
+
try {
|
|
2059
|
+
return runValidated(validation, ctx, finalize, wrapResponse);
|
|
1312
2060
|
}
|
|
1313
|
-
|
|
2061
|
+
catch (err) {
|
|
2062
|
+
return logError(err, ctx, wrapResponse);
|
|
2063
|
+
}
|
|
2064
|
+
};
|
|
2065
|
+
return (source, params, search, signal, budget, platform, nativeContext, finalize, wrapResponse) => {
|
|
2066
|
+
const ctx = nativeContext
|
|
2067
|
+
? RequestContext.native(source, params, search, this.maxBodyBytes, platform)
|
|
2068
|
+
: new RequestContext(source, params, search, signal, budget, platform, this.maxBodyBytes);
|
|
2069
|
+
const finish = (value) => runParsed(value, ctx, finalize, wrapResponse);
|
|
2070
|
+
return this.readBodyInput(source, finish, wrapResponse, (err) => logError(err, ctx, wrapResponse));
|
|
1314
2071
|
};
|
|
1315
2072
|
}
|
|
2073
|
+
/** Web adapter wrapper for the shared body runner. Node passes its native finalizer directly through
|
|
2074
|
+
* the execution plan, while Web needs the live context to preserve lazy `c.set` controls. */
|
|
2075
|
+
buildFusedBodyWeb(body) {
|
|
2076
|
+
return (source, params, search, signal, budget, platform, nativeContext) => body(source, params, search, signal, budget, platform, nativeContext, (result, _set, ctx) => fusedRespond(result, ctx, this.responseBodyTag, this.staticResponseHeaders), this.wrapWebResponse);
|
|
2077
|
+
}
|
|
1316
2078
|
/** The fused Web lane for a route whose only lifecycle step is a query schema: parse + validate +
|
|
1317
2079
|
* handler + respond in one closure, no lifecycle promise when the validator and handler are sync.
|
|
1318
2080
|
* Eligibility is decided at registration (see `fusedQuery` in {@link register}); the semantics here
|
|
@@ -1321,11 +2083,13 @@ export class Server {
|
|
|
1321
2083
|
* `Response` passes through, anything else logs and returns a flat 500, and an async validator
|
|
1322
2084
|
* falls to a then-chain with the same steps. */
|
|
1323
2085
|
buildFusedQueryWeb(handler, decorations, querySchema) {
|
|
2086
|
+
// The fused lanes return to the runtime directly, so the framework's own error renders fold in
|
|
2087
|
+
// static headers here rather than through the shared `wrapResponse` seam.
|
|
1324
2088
|
const logError = (err, ctx) => {
|
|
1325
2089
|
if (err instanceof Response)
|
|
1326
|
-
return err;
|
|
2090
|
+
return this.wrapWebResponse(err);
|
|
1327
2091
|
this.logRequestError(err, ctx);
|
|
1328
|
-
return jsonError(500, "internal_error");
|
|
2092
|
+
return this.wrapWebResponse(jsonError(500, "internal_error"));
|
|
1329
2093
|
};
|
|
1330
2094
|
const runHandler = (ctx, value) => {
|
|
1331
2095
|
ctx.query = value;
|
|
@@ -1337,13 +2101,13 @@ export class Server {
|
|
|
1337
2101
|
return logError(err, ctx);
|
|
1338
2102
|
}
|
|
1339
2103
|
if (result instanceof Promise) {
|
|
1340
|
-
return result.then((settled) => fusedRespond(settled, ctx), (err) => logError(err, ctx));
|
|
2104
|
+
return result.then((settled) => fusedRespond(settled, ctx, this.responseBodyTag, this.staticResponseHeaders), (err) => logError(err, ctx));
|
|
1341
2105
|
}
|
|
1342
|
-
return fusedRespond(result, ctx);
|
|
2106
|
+
return fusedRespond(result, ctx, this.responseBodyTag, this.staticResponseHeaders);
|
|
1343
2107
|
};
|
|
1344
2108
|
return (source, params, search, signal, budget, platform, nativeContext) => {
|
|
1345
2109
|
const ctx = nativeContext
|
|
1346
|
-
? RequestContext.native(source, params, this.maxBodyBytes)
|
|
2110
|
+
? RequestContext.native(source, params, search, this.maxBodyBytes, platform)
|
|
1347
2111
|
: new RequestContext(source, params, search, signal, budget, platform, this.maxBodyBytes);
|
|
1348
2112
|
if (decorations !== undefined)
|
|
1349
2113
|
Object.assign(ctx, decorations);
|
|
@@ -1356,11 +2120,11 @@ export class Server {
|
|
|
1356
2120
|
}
|
|
1357
2121
|
if (validation instanceof Promise) {
|
|
1358
2122
|
return validation.then((settled) => settled.issues !== undefined
|
|
1359
|
-
? validationError(settled.issues)
|
|
2123
|
+
? this.wrapWebResponse(validationError(settled.issues))
|
|
1360
2124
|
: runHandler(ctx, settled.value), (err) => logError(err, ctx));
|
|
1361
2125
|
}
|
|
1362
2126
|
if (validation.issues !== undefined)
|
|
1363
|
-
return validationError(validation.issues);
|
|
2127
|
+
return this.wrapWebResponse(validationError(validation.issues));
|
|
1364
2128
|
return runHandler(ctx, validation.value);
|
|
1365
2129
|
};
|
|
1366
2130
|
}
|
|
@@ -1398,25 +2162,20 @@ export class Server {
|
|
|
1398
2162
|
return dispatch(0);
|
|
1399
2163
|
}
|
|
1400
2164
|
runBodyOnly(entry, source, ctx, finalize, wrapResponse) {
|
|
2165
|
+
return this.readBodyInput(source, (parsed) => this.finishBodyOnly(entry, parsed, ctx, finalize, wrapResponse), wrapResponse, (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse));
|
|
2166
|
+
}
|
|
2167
|
+
/** Shared bounded body framing/parser. Both body lanes use the same trust-boundary checks; only
|
|
2168
|
+
* the validation + handler continuation differs. */
|
|
2169
|
+
readBodyInput(source, onParsed, wrapResponse, onError) {
|
|
1401
2170
|
const contentType = headerOf(source, "content-type") ?? "";
|
|
1402
2171
|
if (contentType !== "application/json" && !contentType.includes("application/json")) {
|
|
1403
2172
|
if (isUrlEncodedForm(contentType)) {
|
|
1404
|
-
|
|
1405
|
-
// Off the JSON inline lane on purpose: forms are a fraction of API traffic, and the
|
|
1406
|
-
// bounded form reader shares the streaming guard. JSON requests are untouched.
|
|
1407
|
-
return readBoundedForm(source, this.maxBodyBytes).then((form) => {
|
|
1408
|
-
if (form instanceof Response)
|
|
1409
|
-
return wrapResponse(form);
|
|
1410
|
-
return this.finishBodyOnly(entry, form, ctx, finalize, wrapResponse);
|
|
1411
|
-
});
|
|
2173
|
+
return readBoundedForm(source, this.maxBodyBytes).then((form) => (form instanceof Response ? wrapResponse(form) : onParsed(form)), onError);
|
|
1412
2174
|
}
|
|
1413
2175
|
return Promise.resolve(wrapResponse(jsonError(415, "unsupported_media_type")));
|
|
1414
2176
|
}
|
|
1415
|
-
//
|
|
1416
|
-
//
|
|
1417
|
-
// `readBoundedJson` (an extra async-fn frame + per-request `finish`/`applyValidation`
|
|
1418
|
-
// closures) is kept for the chunked / length-less / oversized cases it exists for.
|
|
1419
|
-
// Semantics are identical to readBoundedJsonSource - same checks, same error codes.
|
|
2177
|
+
// A framed, in-cap, non-chunked body can use native json() directly. Chunked or length-less
|
|
2178
|
+
// bodies use readBoundedJson, which enforces the streaming cap before parsing.
|
|
1420
2179
|
const declared = headerOf(source, "content-length");
|
|
1421
2180
|
if (declared !== null) {
|
|
1422
2181
|
const length = parseContentLength(declared);
|
|
@@ -1427,21 +2186,14 @@ export class Server {
|
|
|
1427
2186
|
return Promise.resolve(wrapResponse(jsonError(413, "payload_too_large")));
|
|
1428
2187
|
}
|
|
1429
2188
|
if (headerOf(source, "transfer-encoding") === null) {
|
|
1430
|
-
return source.json().then((
|
|
1431
|
-
// Native json() rejection = malformed JSON; anything past parse goes through
|
|
1432
|
-
// finishBodyOnly's own error handling.
|
|
1433
|
-
() => wrapResponse(jsonError(400, "invalid_json")));
|
|
2189
|
+
return source.json().then(onParsed, () => wrapResponse(jsonError(400, "invalid_json")));
|
|
1434
2190
|
}
|
|
1435
2191
|
}
|
|
1436
2192
|
try {
|
|
1437
|
-
return this.readBoundedJson(source).then((parsed) =>
|
|
1438
|
-
if (parsed instanceof Response)
|
|
1439
|
-
return wrapResponse(parsed);
|
|
1440
|
-
return this.finishBodyOnly(entry, parsed, ctx, finalize, wrapResponse);
|
|
1441
|
-
}, (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse));
|
|
2193
|
+
return this.readBoundedJson(source).then((parsed) => (parsed instanceof Response ? wrapResponse(parsed) : onParsed(parsed)), onError);
|
|
1442
2194
|
}
|
|
1443
2195
|
catch (err) {
|
|
1444
|
-
return
|
|
2196
|
+
return Promise.resolve(onError(err));
|
|
1445
2197
|
}
|
|
1446
2198
|
}
|
|
1447
2199
|
/** Validate + run the handler for the bodyOnly path - shared by the inline fast path and the
|
|
@@ -1622,7 +2374,11 @@ export class Server {
|
|
|
1622
2374
|
throw error;
|
|
1623
2375
|
});
|
|
1624
2376
|
}
|
|
1625
|
-
|
|
2377
|
+
// A hook failure must surface as a REJECTION even when every prior step ran synchronously:
|
|
2378
|
+
// `fetch()` may now resolve without a promise, but its failure contract stays promise-shaped -
|
|
2379
|
+
// a synchronous throw here would escape `Promise.resolve(app.fetch(...))` bridges and
|
|
2380
|
+
// `.then()`-style callers entirely instead of reaching their rejection handling.
|
|
2381
|
+
return Promise.reject(error);
|
|
1626
2382
|
}
|
|
1627
2383
|
/** Notify terminal observers in order while isolating both sync and async failures. */
|
|
1628
2384
|
notifyResponseFinalized(outcome, req) {
|
|
@@ -1685,100 +2441,314 @@ export class Server {
|
|
|
1685
2441
|
* Generic over the render: a success value goes through `finalize(result, set)` (a Web `Response`, or
|
|
1686
2442
|
* node-direct primitives); an early/error `Response` (validation, thrown, 500) through `wrapResponse`.
|
|
1687
2443
|
*/
|
|
1688
|
-
|
|
2444
|
+
/** Synchronous until a validator, lifecycle hook, handler, or contract check actually returns a Promise. */
|
|
2445
|
+
runLifecycle(entry, source, ctx, finalize, wrapResponse) {
|
|
2446
|
+
try {
|
|
2447
|
+
const paramsSchema = entry.schema?.params;
|
|
2448
|
+
if (paramsSchema !== undefined) {
|
|
2449
|
+
const validation = paramsSchema["~standard"].validate(ctx.params);
|
|
2450
|
+
if (validation instanceof Promise) {
|
|
2451
|
+
return validation.then((result) => this.runLifecycleAfterParamsResult(entry, source, ctx, finalize, wrapResponse, result), (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse));
|
|
2452
|
+
}
|
|
2453
|
+
return this.runLifecycleAfterParamsResult(entry, source, ctx, finalize, wrapResponse, validation);
|
|
2454
|
+
}
|
|
2455
|
+
// Registration already knows whether this route has body/params/query validation. Most real
|
|
2456
|
+
// read routes have no body or params schema, so skip the generic stage ladder and go straight to
|
|
2457
|
+
// query validation + lifecycle hooks. The fallback below remains the complete path for routes
|
|
2458
|
+
// with body/params combinations and validation recovery.
|
|
2459
|
+
if (entry.schema?.body === undefined) {
|
|
2460
|
+
return this.runQueryAndLifecycle(entry, ctx, finalize, wrapResponse);
|
|
2461
|
+
}
|
|
2462
|
+
return this.runLifecycleAfterParams(entry, source, ctx, finalize, wrapResponse, undefined);
|
|
2463
|
+
}
|
|
2464
|
+
catch (err) {
|
|
2465
|
+
return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse);
|
|
2466
|
+
}
|
|
2467
|
+
}
|
|
2468
|
+
/** Registration-specialized query lifecycle: query validation (including recovery) → hooks. */
|
|
2469
|
+
runLifecycleQuery(entry, ctx, finalize, wrapResponse) {
|
|
2470
|
+
try {
|
|
2471
|
+
// The execution plan only selects this runner when the route has a query schema. Keeping the
|
|
2472
|
+
// non-null access here removes the per-request schema-presence branch from realistic GETs.
|
|
2473
|
+
const validation = entry.schema.query["~standard"].validate(queryObjectOf(ctx[CONTEXT_SEARCH]));
|
|
2474
|
+
if (validation instanceof Promise) {
|
|
2475
|
+
return validation.then((result) => {
|
|
2476
|
+
try {
|
|
2477
|
+
return this.runQueryAndLifecycleResult(entry, ctx, finalize, wrapResponse, result);
|
|
2478
|
+
}
|
|
2479
|
+
catch (err) {
|
|
2480
|
+
return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse);
|
|
2481
|
+
}
|
|
2482
|
+
}, (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse));
|
|
2483
|
+
}
|
|
2484
|
+
return this.runQueryAndLifecycleResult(entry, ctx, finalize, wrapResponse, validation);
|
|
2485
|
+
}
|
|
2486
|
+
catch (err) {
|
|
2487
|
+
return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse);
|
|
2488
|
+
}
|
|
2489
|
+
}
|
|
2490
|
+
/** Registration-specialized body lifecycle: bounded body validation (including recovery) → hooks. */
|
|
2491
|
+
runLifecycleBody(entry, source, ctx, finalize, wrapResponse) {
|
|
2492
|
+
return this.readAndValidateBody(source, entry, ctx).then((bodyError) => bodyError === undefined
|
|
2493
|
+
? this.runLifecycleHooks(entry, ctx, finalize, wrapResponse)
|
|
2494
|
+
: wrapResponse(bodyError), (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse));
|
|
2495
|
+
}
|
|
2496
|
+
/** Registration-specialized body + query lifecycle: bounded body validation → query validation → hooks. */
|
|
2497
|
+
runLifecycleBodyQuery(entry, source, ctx, finalize, wrapResponse) {
|
|
2498
|
+
return this.readAndValidateBody(source, entry, ctx).then((bodyError) => bodyError === undefined
|
|
2499
|
+
? this.runLifecycleQuery(entry, ctx, finalize, wrapResponse)
|
|
2500
|
+
: wrapResponse(bodyError), (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse));
|
|
2501
|
+
}
|
|
2502
|
+
/** Registration-specialized no-body lifecycle: query validation (if present) → derives/hooks. */
|
|
2503
|
+
runQueryAndLifecycle(entry, ctx, finalize, wrapResponse) {
|
|
2504
|
+
const querySchema = entry.schema?.query;
|
|
2505
|
+
return querySchema === undefined
|
|
2506
|
+
? this.runLifecycleHooks(entry, ctx, finalize, wrapResponse)
|
|
2507
|
+
: this.runLifecycleQuery(entry, ctx, finalize, wrapResponse);
|
|
2508
|
+
}
|
|
2509
|
+
runQueryAndLifecycleResult(entry, ctx, finalize, wrapResponse, result) {
|
|
2510
|
+
const queryError = this.applyLifecycleValidation(entry, result, ctx, "query");
|
|
2511
|
+
if (queryError instanceof Promise) {
|
|
2512
|
+
return queryError.then((error) => error === undefined
|
|
2513
|
+
? this.runLifecycleHooks(entry, ctx, finalize, wrapResponse)
|
|
2514
|
+
: wrapResponse(error), (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse));
|
|
2515
|
+
}
|
|
2516
|
+
if (queryError !== undefined)
|
|
2517
|
+
return wrapResponse(queryError);
|
|
2518
|
+
return this.runLifecycleHooks(entry, ctx, finalize, wrapResponse);
|
|
2519
|
+
}
|
|
2520
|
+
runLifecycleAfterParamsResult(entry, source, ctx, finalize, wrapResponse, result) {
|
|
2521
|
+
try {
|
|
2522
|
+
return this.runLifecycleAfterParams(entry, source, ctx, finalize, wrapResponse, this.applyLifecycleValidation(entry, result, ctx, "params"));
|
|
2523
|
+
}
|
|
2524
|
+
catch (err) {
|
|
2525
|
+
return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse);
|
|
2526
|
+
}
|
|
2527
|
+
}
|
|
2528
|
+
runLifecycleAfterParams(entry, source, ctx, finalize, wrapResponse, paramsError) {
|
|
2529
|
+
if (paramsError instanceof Promise) {
|
|
2530
|
+
return paramsError.then((error) => this.runLifecycleAfterParamsSettled(entry, source, ctx, finalize, wrapResponse, error), (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse));
|
|
2531
|
+
}
|
|
2532
|
+
return this.runLifecycleAfterParamsSettled(entry, source, ctx, finalize, wrapResponse, paramsError);
|
|
2533
|
+
}
|
|
2534
|
+
runLifecycleAfterParamsSettled(entry, source, ctx, finalize, wrapResponse, paramsError) {
|
|
2535
|
+
if (paramsError !== undefined)
|
|
2536
|
+
return wrapResponse(paramsError);
|
|
2537
|
+
if (entry.schema?.body !== undefined) {
|
|
2538
|
+
return this.readAndValidateBody(source, entry, ctx).then((bodyError) => this.runLifecycleAfterBody(entry, ctx, finalize, wrapResponse, bodyError), (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse));
|
|
2539
|
+
}
|
|
2540
|
+
return this.runLifecycleAfterBody(entry, ctx, finalize, wrapResponse, undefined);
|
|
2541
|
+
}
|
|
2542
|
+
runLifecycleAfterBody(entry, ctx, finalize, wrapResponse, bodyError) {
|
|
2543
|
+
if (bodyError !== undefined)
|
|
2544
|
+
return wrapResponse(bodyError);
|
|
2545
|
+
const querySchema = entry.schema?.query;
|
|
2546
|
+
if (querySchema === undefined)
|
|
2547
|
+
return this.runLifecycleHooks(entry, ctx, finalize, wrapResponse);
|
|
2548
|
+
const validation = querySchema["~standard"].validate(queryObjectOf(ctx[CONTEXT_SEARCH]));
|
|
2549
|
+
if (validation instanceof Promise) {
|
|
2550
|
+
return validation.then((result) => this.runLifecycleAfterQueryResult(entry, ctx, finalize, wrapResponse, result), (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse));
|
|
2551
|
+
}
|
|
2552
|
+
return this.runLifecycleAfterQueryResult(entry, ctx, finalize, wrapResponse, validation);
|
|
2553
|
+
}
|
|
2554
|
+
runLifecycleAfterQueryResult(entry, ctx, finalize, wrapResponse, result) {
|
|
2555
|
+
try {
|
|
2556
|
+
const queryError = this.applyLifecycleValidation(entry, result, ctx, "query");
|
|
2557
|
+
if (queryError instanceof Promise) {
|
|
2558
|
+
return queryError.then((error) => this.runLifecycleAfterQuery(entry, ctx, finalize, wrapResponse, error), (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse));
|
|
2559
|
+
}
|
|
2560
|
+
return this.runLifecycleAfterQuery(entry, ctx, finalize, wrapResponse, queryError);
|
|
2561
|
+
}
|
|
2562
|
+
catch (err) {
|
|
2563
|
+
return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse);
|
|
2564
|
+
}
|
|
2565
|
+
}
|
|
2566
|
+
runLifecycleAfterQuery(entry, ctx, finalize, wrapResponse, queryError) {
|
|
2567
|
+
if (queryError !== undefined)
|
|
2568
|
+
return wrapResponse(queryError);
|
|
2569
|
+
return this.runLifecycleHooks(entry, ctx, finalize, wrapResponse);
|
|
2570
|
+
}
|
|
2571
|
+
runLifecycleHooks(entry, ctx, finalize, wrapResponse) {
|
|
2572
|
+
if (entry.lifecycleHookLane === "derive-before") {
|
|
2573
|
+
return this.runLifecycleDeriveBefore(entry, ctx, finalize, wrapResponse);
|
|
2574
|
+
}
|
|
1689
2575
|
try {
|
|
1690
|
-
if (entry.schema?.params !== undefined) {
|
|
1691
|
-
// Path params arrive as strings from routing; validate (and coerce) them at the boundary, before
|
|
1692
|
-
// the body/query, so a malformed `:id` is a 422 before any work runs. Raw `.validate` (no wrapper
|
|
1693
|
-
// alloc), same recovery-hook path as body/query.
|
|
1694
|
-
const validation = entry.schema.params["~standard"].validate(ctx.params);
|
|
1695
|
-
const result = validation instanceof Promise ? await validation : validation;
|
|
1696
|
-
const paramsError = await this.applyLifecycleValidation(entry, result, ctx, "params");
|
|
1697
|
-
if (paramsError !== undefined)
|
|
1698
|
-
return wrapResponse(paramsError);
|
|
1699
|
-
}
|
|
1700
|
-
if (entry.schema?.body !== undefined) {
|
|
1701
|
-
const bodyError = await this.readAndValidateBody(source, entry, ctx);
|
|
1702
|
-
if (bodyError !== undefined)
|
|
1703
|
-
return wrapResponse(bodyError);
|
|
1704
|
-
}
|
|
1705
|
-
if (entry.schema?.query !== undefined) {
|
|
1706
|
-
// Repeated query keys promote to `string[]` (so an array schema validates `?a=1&a=2`); a
|
|
1707
|
-
// single occurrence stays a string. Raw `.validate` (read `.issues`/`.value`) skips the
|
|
1708
|
-
// validateStandard wrapper alloc.
|
|
1709
|
-
const validation = entry.schema.query["~standard"].validate(queryObjectOf(ctx[CONTEXT_SEARCH]));
|
|
1710
|
-
const result = validation instanceof Promise ? await validation : validation;
|
|
1711
|
-
const queryError = await this.applyLifecycleValidation(entry, result, ctx, "query");
|
|
1712
|
-
if (queryError !== undefined)
|
|
1713
|
-
return wrapResponse(queryError);
|
|
1714
|
-
}
|
|
1715
|
-
// Context extensions: static decorations, then per-request derives.
|
|
1716
|
-
// Each hook below only awaits when it actually returns a Promise - a sync hook
|
|
1717
|
-
// skips the microtask tick (a fast-path; ~64 ns/hook).
|
|
1718
2576
|
if (entry.hasDecorations)
|
|
1719
|
-
Object.assign(ctx, entry.decorations);
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
for (const derive of entry.derives) {
|
|
1725
|
-
const extension = derive(ctx);
|
|
1726
|
-
Object.assign(ctx, extension instanceof Promise ? await extension : extension);
|
|
2577
|
+
Object.assign(ctx, entry.decorations);
|
|
2578
|
+
for (let i = 0; i < entry.derives.length; i++) {
|
|
2579
|
+
const extension = entry.derives[i](ctx);
|
|
2580
|
+
if (extension instanceof Promise) {
|
|
2581
|
+
return this.continueLifecycleAfterDerive(entry, ctx, finalize, wrapResponse, i, extension);
|
|
1727
2582
|
}
|
|
2583
|
+
Object.assign(ctx, extension);
|
|
1728
2584
|
}
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
const early = outcome instanceof Promise ? await outcome : outcome;
|
|
1734
|
-
if (early !== undefined)
|
|
1735
|
-
return finalize(early, responseSet(ctx));
|
|
2585
|
+
for (let i = 0; i < entry.beforeHandle.length; i++) {
|
|
2586
|
+
const outcome = entry.beforeHandle[i](ctx);
|
|
2587
|
+
if (outcome instanceof Promise) {
|
|
2588
|
+
return this.continueLifecycleAfterBefore(entry, ctx, finalize, wrapResponse, i, outcome);
|
|
1736
2589
|
}
|
|
2590
|
+
if (outcome !== undefined)
|
|
2591
|
+
return finalize(outcome, responseSet(ctx));
|
|
2592
|
+
}
|
|
2593
|
+
const result = entry.handler(ctx);
|
|
2594
|
+
if (result instanceof Promise) {
|
|
2595
|
+
return result.then((value) => this.finishLifecycleResult(entry, ctx, finalize, wrapResponse, value), (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse));
|
|
2596
|
+
}
|
|
2597
|
+
return this.finishLifecycleResult(entry, ctx, finalize, wrapResponse, result);
|
|
2598
|
+
}
|
|
2599
|
+
catch (err) {
|
|
2600
|
+
return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse);
|
|
2601
|
+
}
|
|
2602
|
+
}
|
|
2603
|
+
/** Registration-specialized derive → before → handler lifecycle. */
|
|
2604
|
+
runLifecycleDeriveBefore(entry, ctx, finalize, wrapResponse) {
|
|
2605
|
+
try {
|
|
2606
|
+
const extension = entry.derives[0](ctx);
|
|
2607
|
+
if (extension instanceof Promise) {
|
|
2608
|
+
return this.continueLifecycleAfterDerive(entry, ctx, finalize, wrapResponse, 0, extension);
|
|
2609
|
+
}
|
|
2610
|
+
Object.assign(ctx, extension);
|
|
2611
|
+
const outcome = entry.beforeHandle[0](ctx);
|
|
2612
|
+
if (outcome instanceof Promise) {
|
|
2613
|
+
return this.continueLifecycleAfterBefore(entry, ctx, finalize, wrapResponse, 0, outcome);
|
|
1737
2614
|
}
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
if (
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
2615
|
+
if (outcome !== undefined)
|
|
2616
|
+
return this.finishSimpleLifecycleResult(entry, ctx, finalize, wrapResponse, outcome);
|
|
2617
|
+
const result = entry.handler(ctx);
|
|
2618
|
+
if (result instanceof Promise) {
|
|
2619
|
+
return result.then((value) => this.finishSimpleLifecycleResult(entry, ctx, finalize, wrapResponse, value), (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse));
|
|
2620
|
+
}
|
|
2621
|
+
return this.finishSimpleLifecycleResult(entry, ctx, finalize, wrapResponse, result);
|
|
2622
|
+
}
|
|
2623
|
+
catch (err) {
|
|
2624
|
+
return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse);
|
|
2625
|
+
}
|
|
2626
|
+
}
|
|
2627
|
+
/** Finalize the specialized lane without rechecking its registration-proven invariants. */
|
|
2628
|
+
finishSimpleLifecycleResult(entry, ctx, finalize, wrapResponse, result) {
|
|
2629
|
+
try {
|
|
2630
|
+
return finalize(result, responseSet(ctx));
|
|
2631
|
+
}
|
|
2632
|
+
catch (err) {
|
|
2633
|
+
return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse);
|
|
2634
|
+
}
|
|
2635
|
+
}
|
|
2636
|
+
async continueLifecycleAfterDerive(entry, ctx, finalize, wrapResponse, firstIndex, first) {
|
|
2637
|
+
try {
|
|
2638
|
+
Object.assign(ctx, await first);
|
|
2639
|
+
for (let i = firstIndex + 1; i < entry.derives.length; i++) {
|
|
2640
|
+
const extension = entry.derives[i](ctx);
|
|
2641
|
+
Object.assign(ctx, extension instanceof Promise ? await extension : extension);
|
|
2642
|
+
}
|
|
2643
|
+
return await this.runLifecycleHooksAsync(entry, ctx, finalize, wrapResponse);
|
|
2644
|
+
}
|
|
2645
|
+
catch (err) {
|
|
2646
|
+
return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse);
|
|
2647
|
+
}
|
|
2648
|
+
}
|
|
2649
|
+
async runLifecycleHooksAsync(entry, ctx, finalize, wrapResponse) {
|
|
2650
|
+
try {
|
|
2651
|
+
for (const hook of entry.beforeHandle) {
|
|
2652
|
+
const outcome = hook(ctx);
|
|
2653
|
+
const early = outcome instanceof Promise ? await outcome : outcome;
|
|
2654
|
+
if (early !== undefined)
|
|
2655
|
+
return finalize(early, responseSet(ctx));
|
|
2656
|
+
}
|
|
2657
|
+
return await this.runLifecycleHandlerAsync(entry, ctx, finalize, wrapResponse);
|
|
2658
|
+
}
|
|
2659
|
+
catch (err) {
|
|
2660
|
+
return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse);
|
|
2661
|
+
}
|
|
2662
|
+
}
|
|
2663
|
+
async continueLifecycleAfterBefore(entry, ctx, finalize, wrapResponse, firstIndex, first) {
|
|
2664
|
+
try {
|
|
2665
|
+
const firstOutcome = await first;
|
|
2666
|
+
if (firstOutcome !== undefined)
|
|
2667
|
+
return finalize(firstOutcome, responseSet(ctx));
|
|
2668
|
+
for (let i = firstIndex + 1; i < entry.beforeHandle.length; i++) {
|
|
2669
|
+
const outcome = entry.beforeHandle[i](ctx);
|
|
2670
|
+
const early = outcome instanceof Promise ? await outcome : outcome;
|
|
2671
|
+
if (early !== undefined)
|
|
2672
|
+
return finalize(early, responseSet(ctx));
|
|
2673
|
+
}
|
|
2674
|
+
return await this.runLifecycleHandlerAsync(entry, ctx, finalize, wrapResponse);
|
|
2675
|
+
}
|
|
2676
|
+
catch (err) {
|
|
2677
|
+
return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse);
|
|
2678
|
+
}
|
|
2679
|
+
}
|
|
2680
|
+
async runLifecycleHandlerAsync(entry, ctx, finalize, wrapResponse) {
|
|
2681
|
+
try {
|
|
2682
|
+
const output = entry.handler(ctx);
|
|
2683
|
+
const result = output instanceof Promise ? await output : output;
|
|
2684
|
+
return await this.finishLifecycleResult(entry, ctx, finalize, wrapResponse, result);
|
|
2685
|
+
}
|
|
2686
|
+
catch (err) {
|
|
2687
|
+
return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse);
|
|
2688
|
+
}
|
|
2689
|
+
}
|
|
2690
|
+
finishLifecycleResult(entry, ctx, finalize, wrapResponse, initial) {
|
|
2691
|
+
try {
|
|
2692
|
+
let result = initial;
|
|
2693
|
+
for (let i = 0; i < entry.afterHandle.length; i++) {
|
|
2694
|
+
const transformed = entry.afterHandle[i](result, ctx);
|
|
2695
|
+
if (transformed instanceof Promise) {
|
|
2696
|
+
return this.continueLifecycleAfterHandle(entry, ctx, finalize, wrapResponse, i, transformed);
|
|
1745
2697
|
}
|
|
2698
|
+
result = transformed;
|
|
1746
2699
|
}
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
2700
|
+
return this.finishLifecycleContract(entry, ctx, finalize, wrapResponse, result);
|
|
2701
|
+
}
|
|
2702
|
+
catch (err) {
|
|
2703
|
+
return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse);
|
|
2704
|
+
}
|
|
2705
|
+
}
|
|
2706
|
+
async continueLifecycleAfterHandle(entry, ctx, finalize, wrapResponse, firstIndex, first) {
|
|
2707
|
+
try {
|
|
2708
|
+
let result = await first;
|
|
2709
|
+
for (let i = firstIndex + 1; i < entry.afterHandle.length; i++) {
|
|
2710
|
+
const transformed = entry.afterHandle[i](result, ctx);
|
|
2711
|
+
result = transformed instanceof Promise ? await transformed : transformed;
|
|
2712
|
+
}
|
|
2713
|
+
return await this.finishLifecycleContract(entry, ctx, finalize, wrapResponse, result);
|
|
2714
|
+
}
|
|
2715
|
+
catch (err) {
|
|
2716
|
+
return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse);
|
|
2717
|
+
}
|
|
2718
|
+
}
|
|
2719
|
+
finishLifecycleContract(entry, ctx, finalize, wrapResponse, result) {
|
|
2720
|
+
try {
|
|
1750
2721
|
const contract = entry.responseContract;
|
|
1751
|
-
if (contract
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
// caller's. Fail closed: the undeclared payload is never sent, and the detail goes to the
|
|
1757
|
-
// (redacting) logger rather than to the client.
|
|
1758
|
-
this.logger.error("response contract violation", {
|
|
1759
|
-
method: ctx.req.method,
|
|
1760
|
-
path: pathnameOf(ctx.req.url),
|
|
1761
|
-
// `detail`, not `message`: the logger uses `message` for its own first argument, so a
|
|
1762
|
-
// field of that name is silently overwritten and the diagnosis disappears.
|
|
1763
|
-
detail: outcome.message,
|
|
1764
|
-
});
|
|
1765
|
-
return wrapResponse(jsonError(500, "internal_error"));
|
|
1766
|
-
}
|
|
1767
|
-
if (outcome.kind === "warn") {
|
|
1768
|
-
this.logger.warn("response contract", {
|
|
1769
|
-
method: ctx.req.method,
|
|
1770
|
-
path: pathnameOf(ctx.req.url),
|
|
1771
|
-
detail: outcome.message,
|
|
1772
|
-
});
|
|
1773
|
-
}
|
|
1774
|
-
result = outcome.value;
|
|
2722
|
+
if (contract === undefined)
|
|
2723
|
+
return finalize(result, responseSet(ctx));
|
|
2724
|
+
const checked = contract.runtime.check(contract.schema, result);
|
|
2725
|
+
if (checked instanceof Promise) {
|
|
2726
|
+
return checked.then((outcome) => this.finishContractOutcome(ctx, finalize, wrapResponse, outcome), (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse));
|
|
1775
2727
|
}
|
|
1776
|
-
return
|
|
2728
|
+
return this.finishContractOutcome(ctx, finalize, wrapResponse, checked);
|
|
1777
2729
|
}
|
|
1778
2730
|
catch (err) {
|
|
1779
2731
|
return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse);
|
|
1780
2732
|
}
|
|
1781
2733
|
}
|
|
2734
|
+
finishContractOutcome(ctx, finalize, wrapResponse, outcome) {
|
|
2735
|
+
if (outcome.kind === "violation") {
|
|
2736
|
+
this.logger.error("response contract violation", {
|
|
2737
|
+
method: ctx.req.method,
|
|
2738
|
+
path: pathnameOf(ctx.req.url),
|
|
2739
|
+
detail: outcome.message,
|
|
2740
|
+
});
|
|
2741
|
+
return wrapResponse(jsonError(500, "internal_error"));
|
|
2742
|
+
}
|
|
2743
|
+
if (outcome.kind === "warn") {
|
|
2744
|
+
this.logger.warn("response contract", {
|
|
2745
|
+
method: ctx.req.method,
|
|
2746
|
+
path: pathnameOf(ctx.req.url),
|
|
2747
|
+
detail: outcome.message,
|
|
2748
|
+
});
|
|
2749
|
+
}
|
|
2750
|
+
return finalize(outcome.value, responseSet(ctx));
|
|
2751
|
+
}
|
|
1782
2752
|
async handleLifecycleError(entry, err, ctx, finalize, wrapResponse) {
|
|
1783
2753
|
// A *thrown* Response is deliberate control flow, not an error - a guard throws a redirect/401,
|
|
1784
2754
|
// an action throws an error page. Return it as-is (Remix/SvelteKit semantics); don't run onError
|
|
@@ -1838,7 +2808,7 @@ export class Server {
|
|
|
1838
2808
|
}
|
|
1839
2809
|
/** Apply validation and its recovery hook on the generic lifecycle lane. Recovery is completed
|
|
1840
2810
|
* before derives/beforeHandle run, matching the body-only and query-only execution lanes. */
|
|
1841
|
-
|
|
2811
|
+
applyLifecycleValidation(entry, result, ctx, kind) {
|
|
1842
2812
|
const assign = (value) => {
|
|
1843
2813
|
if (kind === "body")
|
|
1844
2814
|
ctx.body = value;
|
|
@@ -1855,9 +2825,14 @@ export class Server {
|
|
|
1855
2825
|
if (hook === undefined)
|
|
1856
2826
|
return validationError(result.issues);
|
|
1857
2827
|
const attempted = hook(result.issues, ctx, kind);
|
|
1858
|
-
|
|
2828
|
+
if (attempted instanceof Promise) {
|
|
2829
|
+
return attempted.then((recovery) => this.finishLifecycleValidationRecovery(entry, kind, result.issues, recovery, assign));
|
|
2830
|
+
}
|
|
2831
|
+
return this.finishLifecycleValidationRecovery(entry, kind, result.issues, attempted, assign);
|
|
2832
|
+
}
|
|
2833
|
+
finishLifecycleValidationRecovery(entry, kind, issues, recovery, assign) {
|
|
1859
2834
|
if (recovery === undefined)
|
|
1860
|
-
return validationError(
|
|
2835
|
+
return validationError(issues);
|
|
1861
2836
|
if (recovery instanceof Response)
|
|
1862
2837
|
return recovery;
|
|
1863
2838
|
const schema = kind === "body"
|
|
@@ -1866,10 +2841,17 @@ export class Server {
|
|
|
1866
2841
|
? entry.schema?.query
|
|
1867
2842
|
: entry.schema?.params;
|
|
1868
2843
|
const retried = schema["~standard"].validate(recovery);
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
2844
|
+
if (retried instanceof Promise) {
|
|
2845
|
+
return retried.then((settled) => {
|
|
2846
|
+
if (settled.issues !== undefined)
|
|
2847
|
+
return validationError(settled.issues);
|
|
2848
|
+
assign(settled.value);
|
|
2849
|
+
return undefined;
|
|
2850
|
+
});
|
|
2851
|
+
}
|
|
2852
|
+
if (retried.issues !== undefined)
|
|
2853
|
+
return validationError(retried.issues);
|
|
2854
|
+
assign(retried.value);
|
|
1873
2855
|
return undefined;
|
|
1874
2856
|
}
|
|
1875
2857
|
/**
|