@nifrajs/core 2.8.2 → 2.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/internal/route-execution.d.ts +8 -2
- package/dist/internal/route-execution.d.ts.map +1 -1
- package/dist/node-direct.d.ts.map +1 -1
- package/dist/node-direct.js +3 -1
- package/dist/node-direct.js.map +1 -1
- package/dist/router/router.d.ts.map +1 -1
- package/dist/router/router.js +26 -6
- package/dist/router/router.js.map +1 -1
- package/dist/server/context.d.ts +2 -0
- package/dist/server/context.d.ts.map +1 -1
- package/dist/server/cookies.d.ts.map +1 -1
- package/dist/server/cookies.js +4 -1
- package/dist/server/cookies.js.map +1 -1
- package/dist/server/http.d.ts.map +1 -1
- package/dist/server/http.js +16 -3
- package/dist/server/http.js.map +1 -1
- package/dist/server/node-outcome-hook.d.ts +78 -1
- package/dist/server/node-outcome-hook.d.ts.map +1 -1
- package/dist/server/node-outcome-hook.js +197 -1
- package/dist/server/node-outcome-hook.js.map +1 -1
- package/dist/server/node-outcome.d.ts +22 -2
- package/dist/server/node-outcome.d.ts.map +1 -1
- package/dist/server/node-outcome.js +86 -11
- package/dist/server/node-outcome.js.map +1 -1
- package/dist/server/query.d.ts +2 -0
- package/dist/server/query.d.ts.map +1 -1
- package/dist/server/query.js +27 -1
- package/dist/server/query.js.map +1 -1
- package/dist/server/request-context.d.ts +8 -1
- package/dist/server/request-context.d.ts.map +1 -1
- package/dist/server/request-context.js +23 -2
- package/dist/server/request-context.js.map +1 -1
- package/dist/server/respond.d.ts +29 -3
- package/dist/server/respond.d.ts.map +1 -1
- package/dist/server/respond.js +279 -16
- package/dist/server/respond.js.map +1 -1
- package/dist/server/response-contract-lane.d.ts +17 -3
- package/dist/server/response-contract-lane.d.ts.map +1 -1
- package/dist/server/response-contract-lane.js +6 -3
- package/dist/server/response-contract-lane.js.map +1 -1
- package/dist/server/server-types.d.ts +37 -0
- package/dist/server/server-types.d.ts.map +1 -1
- package/dist/server/server.d.ts +151 -7
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +1163 -174
- package/dist/server/server.js.map +1 -1
- package/dist/server/static-headers.d.ts +47 -0
- package/dist/server/static-headers.d.ts.map +1 -0
- package/dist/server/static-headers.js +109 -0
- package/dist/server/static-headers.js.map +1 -0
- package/dist/server.d.ts +2 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +1 -0
- package/dist/server.js.map +1 -1
- package/dist/transport-codec.d.ts +15 -0
- package/dist/transport-codec.d.ts.map +1 -1
- package/dist/transport-codec.js +49 -10
- package/dist/transport-codec.js.map +1 -1
- package/package.json +1 -1
- package/src/index.ts +1 -1
- package/src/internal/route-execution.ts +19 -1
- package/src/node-direct.ts +7 -1
- package/src/router/router.ts +24 -6
- package/src/server/context.ts +2 -0
- package/src/server/cookies.ts +4 -1
- package/src/server/http.ts +16 -3
- package/src/server/node-outcome-hook.ts +315 -1
- package/src/server/node-outcome.ts +100 -14
- package/src/server/query.ts +26 -1
- package/src/server/request-context.ts +23 -1
- package/src/server/respond.ts +386 -17
- package/src/server/response-contract-lane.ts +17 -3
- package/src/server/server-types.ts +42 -0
- package/src/server/server.ts +1618 -237
- package/src/server/static-headers.ts +142 -0
- package/src/server.ts +9 -0
- package/src/transport-codec.ts +55 -10
package/dist/server/server.js
CHANGED
|
@@ -9,10 +9,150 @@ import { EMPTY_PARAMS, Router } from "../router/router.js";
|
|
|
9
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. */
|
|
@@ -223,9 +388,20 @@ export class Server {
|
|
|
223
388
|
this.onErrorHooks = [];
|
|
224
389
|
this.aroundHooks = [];
|
|
225
390
|
this.onRequestHooks = [];
|
|
391
|
+
this.onNodeRequestHooks = [];
|
|
392
|
+
this.nodeRequestHooksComplete = true;
|
|
226
393
|
this.onResponseHooks = [];
|
|
394
|
+
this.onNodeResponseHooks = [];
|
|
395
|
+
this.nodeResponseHooksComplete = true;
|
|
227
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();
|
|
228
402
|
this.responseRequests = new WeakMap();
|
|
403
|
+
this.responseSources = new WeakMap();
|
|
404
|
+
this.nodeContexts = new WeakMap();
|
|
229
405
|
this.appliedPlugins = new Set();
|
|
230
406
|
this.activeAssurance = [];
|
|
231
407
|
this.globalAssurance = [];
|
|
@@ -256,6 +432,8 @@ export class Server {
|
|
|
256
432
|
onRequest(fn) {
|
|
257
433
|
this.assertConfigurable("onRequest()");
|
|
258
434
|
this.onRequestHooks.push(fn);
|
|
435
|
+
this.onNodeRequestHooks.push(undefined);
|
|
436
|
+
this.nodeRequestHooksComplete = false;
|
|
259
437
|
return this;
|
|
260
438
|
}
|
|
261
439
|
/** Run after validation, before the handler; a non-`undefined` return short-circuits. Order-scoped. */
|
|
@@ -317,6 +495,115 @@ export class Server {
|
|
|
317
495
|
onResponse(fn) {
|
|
318
496
|
this.assertConfigurable("onResponse()");
|
|
319
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
|
+
});
|
|
320
607
|
return this;
|
|
321
608
|
}
|
|
322
609
|
/** Observe the terminal response after all transformations. Observers are ordered and fail-open. */
|
|
@@ -363,16 +650,42 @@ export class Server {
|
|
|
363
650
|
}
|
|
364
651
|
this.globalAssurance.push(...evidence.filter((item) => item.scope === "global"));
|
|
365
652
|
this.activeAssurance.push(...evidence.filter((item) => item.scope === "subsequent"));
|
|
366
|
-
if (arg.onRequest !== undefined)
|
|
367
|
-
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
|
+
}
|
|
368
663
|
if (arg.around !== undefined)
|
|
369
664
|
this.around(arg.around);
|
|
370
665
|
if (arg.beforeHandle !== undefined)
|
|
371
666
|
this.beforeHandle(arg.beforeHandle);
|
|
372
667
|
if (arg.afterHandle !== undefined)
|
|
373
668
|
this.afterHandle(arg.afterHandle);
|
|
374
|
-
if (arg.onResponse !== undefined)
|
|
375
|
-
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);
|
|
376
689
|
if (arg.onResponseFinalized !== undefined)
|
|
377
690
|
this.onResponseFinalized(arg.onResponseFinalized);
|
|
378
691
|
if (arg.onError !== undefined)
|
|
@@ -558,7 +871,9 @@ export class Server {
|
|
|
558
871
|
const ledgered = this.effectLedgerRuntime?.resolve(capabilities, method, path);
|
|
559
872
|
// A checked response schema needs the handler's VALUE before it becomes bytes, and the fused and
|
|
560
873
|
// native lanes exist precisely to skip that step. So a contracted route leaves them - the same
|
|
561
|
-
// 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).
|
|
562
877
|
const runtime = this.responseContractRuntime;
|
|
563
878
|
const contracted = runtime !== undefined && schema?.response !== undefined
|
|
564
879
|
? { runtime, schema: schema.response }
|
|
@@ -593,24 +908,40 @@ export class Server {
|
|
|
593
908
|
this.afterHandleHooks.length === 0 &&
|
|
594
909
|
this.onErrorHooks.length === 0 &&
|
|
595
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;
|
|
596
932
|
const fusedWeb = bare && this.aroundHooks.length === 0
|
|
597
933
|
? this.buildFusedWeb(handler, hasDecorations ? routeDecorations : undefined, isContextlessNoArgArrow(handler))
|
|
598
934
|
: fusedQuery
|
|
599
935
|
? this.buildFusedQueryWeb(handler, hasDecorations ? routeDecorations : undefined, schema.query)
|
|
600
|
-
:
|
|
936
|
+
: fusedBody
|
|
937
|
+
? this.buildFusedBodyWeb(fusedBodyRunner)
|
|
938
|
+
: undefined;
|
|
601
939
|
const contextless = bare && this.aroundHooks.length === 0 && isContextlessNoArgArrow(handler);
|
|
602
940
|
// The body and query lanes finalize the handler's result themselves, so a contracted route takes
|
|
603
941
|
// the general lifecycle lane instead - one place owns the check rather than three.
|
|
604
942
|
const lane = bare
|
|
605
943
|
? "bare"
|
|
606
|
-
:
|
|
607
|
-
schema?.body !== undefined &&
|
|
608
|
-
schema.query === undefined &&
|
|
609
|
-
schema.params === undefined &&
|
|
610
|
-
this.derives.length === 0 &&
|
|
611
|
-
this.beforeHandleHooks.length === 0 &&
|
|
612
|
-
this.afterHandleHooks.length === 0 &&
|
|
613
|
-
this.onErrorHooks.length === 0
|
|
944
|
+
: bodyOnly
|
|
614
945
|
? "body"
|
|
615
946
|
: contracted === undefined &&
|
|
616
947
|
schema?.body === undefined &&
|
|
@@ -622,8 +953,34 @@ export class Server {
|
|
|
622
953
|
this.onErrorHooks.length === 0
|
|
623
954
|
? "query"
|
|
624
955
|
: "lifecycle";
|
|
625
|
-
|
|
626
|
-
|
|
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 = {
|
|
627
984
|
// (context: never) => unknown -> InternalHandler: the framework invokes it
|
|
628
985
|
// with the concrete RawContext the typed handler expects, so this is sound.
|
|
629
986
|
handler: handler,
|
|
@@ -637,6 +994,7 @@ export class Server {
|
|
|
637
994
|
beforeHandle: [...this.beforeHandleHooks],
|
|
638
995
|
afterHandle: [...this.afterHandleHooks],
|
|
639
996
|
onError: [...this.onErrorHooks],
|
|
997
|
+
lifecycleHookLane,
|
|
640
998
|
around: [...this.aroundHooks],
|
|
641
999
|
execution,
|
|
642
1000
|
};
|
|
@@ -677,17 +1035,17 @@ export class Server {
|
|
|
677
1035
|
method,
|
|
678
1036
|
path,
|
|
679
1037
|
pattern,
|
|
680
|
-
entry,
|
|
1038
|
+
entry: registeredEntry,
|
|
681
1039
|
descriptor,
|
|
682
1040
|
assurance: Object.freeze(routeAssurance),
|
|
683
1041
|
};
|
|
684
1042
|
}
|
|
685
1043
|
/** Collapse route-invariant lifecycle decisions into one runner at registration. The request path
|
|
686
1044
|
* performs no eligibility ladder: it supplies request state to this already-selected plan. */
|
|
687
|
-
compileExecutionPlan(lane, contextless, hasAround, hasLedger, fusedWeb, fusedLane) {
|
|
1045
|
+
compileExecutionPlan(lane, contextless, hasAround, hasLedger, lifecycleLane, fusedWeb, fusedBody, fusedLane) {
|
|
688
1046
|
if (contextless) {
|
|
689
|
-
const run = (runtime, entry, source, params, search, signal, budget, platform, finalize, wrapResponse) => runtime.runContextlessBare(entry, source, params, search, signal, budget, platform, finalize, wrapResponse);
|
|
690
|
-
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 });
|
|
691
1049
|
}
|
|
692
1050
|
let inner;
|
|
693
1051
|
switch (lane) {
|
|
@@ -701,13 +1059,30 @@ export class Server {
|
|
|
701
1059
|
inner = (runtime, entry, _source, ctx, finalize, wrapResponse) => runtime.runQueryOnly(entry, ctx, finalize, wrapResponse);
|
|
702
1060
|
break;
|
|
703
1061
|
default:
|
|
704
|
-
|
|
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
|
+
}
|
|
705
1078
|
}
|
|
706
1079
|
const execute = hasAround
|
|
707
1080
|
? (runtime, entry, source, ctx, finalize, wrapResponse) => runtime.runWithAround(entry, ctx, () => inner(runtime, entry, source, ctx, finalize, wrapResponse), finalize, wrapResponse)
|
|
708
1081
|
: inner;
|
|
709
|
-
const run = (runtime, entry, source, params, search, signal, budget, platform, finalize, wrapResponse) => {
|
|
710
|
-
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);
|
|
711
1086
|
let ledger;
|
|
712
1087
|
// The runtime is always present when a route resolved a ledger (enforced at registration).
|
|
713
1088
|
const ledgerRuntime = runtime.effectLedgerRuntime;
|
|
@@ -724,12 +1099,12 @@ export class Server {
|
|
|
724
1099
|
}
|
|
725
1100
|
return outcome;
|
|
726
1101
|
};
|
|
727
|
-
return Object.freeze({ run, fusedWeb, fusedLane });
|
|
1102
|
+
return Object.freeze({ run, fusedWeb, fusedBody, fusedLane });
|
|
728
1103
|
}
|
|
729
1104
|
/** The idempotency lane's bridge back into the normal matched lanes, resolved to a concrete Response
|
|
730
1105
|
* (the lane buffers the body, then replays it through the route's real validation + handler). */
|
|
731
1106
|
idempotencyRunLanes(buffered, platform, entry, params, search) {
|
|
732
|
-
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));
|
|
733
1108
|
}
|
|
734
1109
|
/** @internal Symbol-keyed install seam for the `responseContract()` plugin. Off the public typed surface. */
|
|
735
1110
|
[INSTALL_RESPONSE_CONTRACT](runtime) {
|
|
@@ -800,8 +1175,23 @@ export class Server {
|
|
|
800
1175
|
this.sseRuntime ??= source.sseRuntime;
|
|
801
1176
|
this.wsRuntime ??= source.wsRuntime;
|
|
802
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
|
+
}
|
|
803
1186
|
this.onResponseHooks.push(...source.onResponseHooks);
|
|
1187
|
+
this.onNodeResponseHooks.push(...source.onNodeResponseHooks);
|
|
1188
|
+
this.nodeResponseHooksComplete &&= source.nodeResponseHooksComplete;
|
|
804
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
|
+
}
|
|
805
1195
|
this.globalAssurance.push(...source.globalAssurance);
|
|
806
1196
|
this.mcpResourceList.push(...source.mcpResourceList);
|
|
807
1197
|
this.mcpPromptList.push(...source.mcpPromptList);
|
|
@@ -815,14 +1205,19 @@ export class Server {
|
|
|
815
1205
|
return route;
|
|
816
1206
|
// Rebuild with the SAME builder that produced the closure - rebinding a query-fused route as
|
|
817
1207
|
// bare would silently drop its validation.
|
|
818
|
-
const
|
|
819
|
-
? this.
|
|
820
|
-
:
|
|
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));
|
|
821
1216
|
return {
|
|
822
1217
|
...route,
|
|
823
1218
|
entry: {
|
|
824
1219
|
...entry,
|
|
825
|
-
execution: Object.freeze({ ...entry.execution, fusedWeb }),
|
|
1220
|
+
execution: Object.freeze({ ...entry.execution, fusedWeb, fusedBody }),
|
|
826
1221
|
},
|
|
827
1222
|
};
|
|
828
1223
|
}
|
|
@@ -863,7 +1258,7 @@ export class Server {
|
|
|
863
1258
|
// path, selected by the compiled execution plan), and an `async fetch` would wrap every such result in a redundant
|
|
864
1259
|
// promise + microtask. Returning `Response | Promise<Response>` matches Web/edge handlers, while
|
|
865
1260
|
// `await app.fetch(...)` continues to work exactly as before.
|
|
866
|
-
const outcome = this.dispatch(source, platform,
|
|
1261
|
+
const outcome = this.dispatch(source, platform, this.finalizeResponse, this.wrapWebResponse, this.webResponseTimeout, true);
|
|
867
1262
|
if (this.onResponseHooks.length === 0 && this.onResponseFinalizedHooks.length === 0) {
|
|
868
1263
|
return outcome;
|
|
869
1264
|
}
|
|
@@ -881,7 +1276,7 @@ export class Server {
|
|
|
881
1276
|
return this.admitGated(requestOf(source), () => this.fetchMatchedInner(source, entry, params));
|
|
882
1277
|
}
|
|
883
1278
|
fetchMatchedInner(source, entry, params) {
|
|
884
|
-
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);
|
|
885
1280
|
if (this.onResponseHooks.length === 0 && this.onResponseFinalizedHooks.length === 0) {
|
|
886
1281
|
return outcome;
|
|
887
1282
|
}
|
|
@@ -931,20 +1326,28 @@ export class Server {
|
|
|
931
1326
|
* Like {@link fetch}, but renders a plain-data result **without** building a Web `Response` - the
|
|
932
1327
|
* `@nifrajs/node` adapter serializes the returned primitives straight to the socket, skipping the undici
|
|
933
1328
|
* `Response` build + body drain (the bulk of the Node bridge cost, measured ≈4µs/req). A handler that
|
|
934
|
-
* returns a `Response`, an error/short-circuit, or
|
|
935
|
-
* full Web path
|
|
936
|
-
*
|
|
937
|
-
* 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.
|
|
938
1332
|
*/
|
|
939
1333
|
resolveNode(req, platform) {
|
|
940
1334
|
return this.resolveNodeSource(req, platform);
|
|
941
1335
|
}
|
|
942
1336
|
resolveNodeSource(source, platform, suppliedRuntime) {
|
|
943
|
-
//
|
|
944
|
-
//
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
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) {
|
|
948
1351
|
const response = this.fetchSource(source, platform);
|
|
949
1352
|
return response instanceof Promise
|
|
950
1353
|
? response.then((settled) => ({ kind: "response", response: settled }))
|
|
@@ -957,7 +1360,151 @@ export class Server {
|
|
|
957
1360
|
if (runtime === undefined) {
|
|
958
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`).");
|
|
959
1362
|
}
|
|
960
|
-
|
|
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
|
+
}
|
|
961
1508
|
}
|
|
962
1509
|
/**
|
|
963
1510
|
* Resolve a WebSocket upgrade - the seam every serving adapter uses. Returns `pass` (not a WS
|
|
@@ -1066,8 +1613,42 @@ export class Server {
|
|
|
1066
1613
|
if (this.onRequestHooks.length === 0) {
|
|
1067
1614
|
return this.routeAndRun(source, resolved, finalize, wrapResponse, onTimeout, webFast);
|
|
1068
1615
|
}
|
|
1616
|
+
if (!webFast && this.canUseNodeRequestHooks()) {
|
|
1617
|
+
return this.runWithNodeRequest(source, resolved, finalize, wrapResponse, onTimeout);
|
|
1618
|
+
}
|
|
1069
1619
|
return this.runWithOnRequest(source, resolved, finalize, wrapResponse, onTimeout, webFast);
|
|
1070
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
|
+
}
|
|
1071
1652
|
/**
|
|
1072
1653
|
* onRequest short-circuit path. Synchronous as long as every hook returns synchronously (the
|
|
1073
1654
|
* common case - e.g. CORS returning `undefined` for a non-preflight request): an `async` version
|
|
@@ -1078,6 +1659,7 @@ export class Server {
|
|
|
1078
1659
|
runWithOnRequest(source, platform, finalize, wrapResponse, onTimeout, webFast) {
|
|
1079
1660
|
const hooks = this.onRequestHooks;
|
|
1080
1661
|
const originalRequest = requestOf(source);
|
|
1662
|
+
this.responseSources.set(source, originalRequest);
|
|
1081
1663
|
let current = source;
|
|
1082
1664
|
for (let i = 0; i < hooks.length; i++) {
|
|
1083
1665
|
const outcome = hooks[i](requestOf(current), platform);
|
|
@@ -1119,6 +1701,16 @@ export class Server {
|
|
|
1119
1701
|
return this.routeAndRun(current, platform, finalize, wrapResponse, onTimeout, webFast);
|
|
1120
1702
|
}
|
|
1121
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
|
+
}
|
|
1122
1714
|
const request = requestOf(source);
|
|
1123
1715
|
const rewritten = this.responseRequests.get(request);
|
|
1124
1716
|
if (rewritten === undefined)
|
|
@@ -1126,14 +1718,99 @@ export class Server {
|
|
|
1126
1718
|
this.responseRequests.delete(request);
|
|
1127
1719
|
return rewritten;
|
|
1128
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
|
+
}
|
|
1129
1765
|
/**
|
|
1130
1766
|
* Route → build context → run. Synchronous through to the handler for a **bare** route
|
|
1131
1767
|
* (selected by its compiled plan), so a sync handler produces its result with zero promise allocations;
|
|
1132
1768
|
* routes with validation/hooks keep the full async {@link runLifecycle}, unchanged.
|
|
1133
1769
|
*/
|
|
1134
1770
|
routeAndRun(source, platform, finalize, wrapResponse, onTimeout, webFast) {
|
|
1135
|
-
|
|
1136
|
-
|
|
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);
|
|
1137
1814
|
if (!match.found) {
|
|
1138
1815
|
if (match.reason === "method-not-allowed") {
|
|
1139
1816
|
return wrapResponse(jsonError(405, "method_not_allowed", { Allow: match.allowed.join(", ") }));
|
|
@@ -1146,7 +1823,7 @@ export class Server {
|
|
|
1146
1823
|
if (params === null) {
|
|
1147
1824
|
return wrapResponse(jsonError(400, "malformed_path"));
|
|
1148
1825
|
}
|
|
1149
|
-
return this.runMatched(source, platform, match.payload, params,
|
|
1826
|
+
return this.runMatched(source, platform, match.payload, params, search, finalize, wrapResponse, onTimeout, webFast);
|
|
1150
1827
|
}
|
|
1151
1828
|
/** Run a route that has already been matched by the runtime or Nifra's portable router. */
|
|
1152
1829
|
runMatched(source, platform, entry, params, search, finalize, wrapResponse, onTimeout, webFast) {
|
|
@@ -1196,9 +1873,12 @@ export class Server {
|
|
|
1196
1873
|
? getUnboundedRequestBudget()
|
|
1197
1874
|
: createRequestBudget({ deadline: admission.deadline, signal });
|
|
1198
1875
|
const plan = entry.execution;
|
|
1876
|
+
const nativeContext = controller === undefined;
|
|
1199
1877
|
const outcome = webFast && plan.fusedWeb !== undefined
|
|
1200
|
-
? plan.fusedWeb(source, params, search, signal, budget, platform,
|
|
1201
|
-
: 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);
|
|
1202
1882
|
// The request timeout only bounds work that is actually pending - a synchronous (bare) result is
|
|
1203
1883
|
// already complete and can't time out, so it's returned as-is (no 503 race, no promise).
|
|
1204
1884
|
if (controller !== undefined && outcome instanceof Promise) {
|
|
@@ -1270,11 +1950,13 @@ export class Server {
|
|
|
1270
1950
|
* 500s), same respond semantics (the lifecycle parity suite pins it).
|
|
1271
1951
|
*/
|
|
1272
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.
|
|
1273
1955
|
const logError = (err, ctx) => {
|
|
1274
1956
|
if (err instanceof Response)
|
|
1275
|
-
return err;
|
|
1957
|
+
return this.wrapWebResponse(err);
|
|
1276
1958
|
this.logRequestError(err, ctx);
|
|
1277
|
-
return jsonError(500, "internal_error");
|
|
1959
|
+
return this.wrapWebResponse(jsonError(500, "internal_error"));
|
|
1278
1960
|
};
|
|
1279
1961
|
if (contextless && decorations === undefined) {
|
|
1280
1962
|
// `() => ...` can't observe the context - skip allocating one entirely (errors still build
|
|
@@ -1287,20 +1969,20 @@ export class Server {
|
|
|
1287
1969
|
}
|
|
1288
1970
|
catch (err) {
|
|
1289
1971
|
return logError(err, nativeContext
|
|
1290
|
-
? RequestContext.native(source, params, this.maxBodyBytes)
|
|
1972
|
+
? RequestContext.native(source, params, search, this.maxBodyBytes, platform)
|
|
1291
1973
|
: new RequestContext(source, params, search, signal, budget, platform, this.maxBodyBytes));
|
|
1292
1974
|
}
|
|
1293
1975
|
if (result instanceof Promise) {
|
|
1294
|
-
return result.then(fusedRespondNoSet, (err) => logError(err, nativeContext
|
|
1295
|
-
? 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)
|
|
1296
1978
|
: new RequestContext(source, params, search, signal, budget, platform, this.maxBodyBytes)));
|
|
1297
1979
|
}
|
|
1298
|
-
return fusedRespondNoSet(result);
|
|
1980
|
+
return fusedRespondNoSet(result, this.responseBodyTag, this.staticResponseHeaders);
|
|
1299
1981
|
};
|
|
1300
1982
|
}
|
|
1301
1983
|
return (source, params, search, signal, budget, platform, nativeContext) => {
|
|
1302
1984
|
const ctx = nativeContext
|
|
1303
|
-
? RequestContext.native(source, params, this.maxBodyBytes)
|
|
1985
|
+
? RequestContext.native(source, params, search, this.maxBodyBytes, platform)
|
|
1304
1986
|
: new RequestContext(source, params, search, signal, budget, platform, this.maxBodyBytes);
|
|
1305
1987
|
if (decorations !== undefined)
|
|
1306
1988
|
Object.assign(ctx, decorations);
|
|
@@ -1312,11 +1994,87 @@ export class Server {
|
|
|
1312
1994
|
return logError(err, ctx);
|
|
1313
1995
|
}
|
|
1314
1996
|
if (result instanceof Promise) {
|
|
1315
|
-
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));
|
|
1316
1998
|
}
|
|
1317
|
-
return fusedRespond(result, ctx);
|
|
1999
|
+
return fusedRespond(result, ctx, this.responseBodyTag, this.staticResponseHeaders);
|
|
1318
2000
|
};
|
|
1319
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);
|
|
2060
|
+
}
|
|
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));
|
|
2071
|
+
};
|
|
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
|
+
}
|
|
1320
2078
|
/** The fused Web lane for a route whose only lifecycle step is a query schema: parse + validate +
|
|
1321
2079
|
* handler + respond in one closure, no lifecycle promise when the validator and handler are sync.
|
|
1322
2080
|
* Eligibility is decided at registration (see `fusedQuery` in {@link register}); the semantics here
|
|
@@ -1325,11 +2083,13 @@ export class Server {
|
|
|
1325
2083
|
* `Response` passes through, anything else logs and returns a flat 500, and an async validator
|
|
1326
2084
|
* falls to a then-chain with the same steps. */
|
|
1327
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.
|
|
1328
2088
|
const logError = (err, ctx) => {
|
|
1329
2089
|
if (err instanceof Response)
|
|
1330
|
-
return err;
|
|
2090
|
+
return this.wrapWebResponse(err);
|
|
1331
2091
|
this.logRequestError(err, ctx);
|
|
1332
|
-
return jsonError(500, "internal_error");
|
|
2092
|
+
return this.wrapWebResponse(jsonError(500, "internal_error"));
|
|
1333
2093
|
};
|
|
1334
2094
|
const runHandler = (ctx, value) => {
|
|
1335
2095
|
ctx.query = value;
|
|
@@ -1341,13 +2101,13 @@ export class Server {
|
|
|
1341
2101
|
return logError(err, ctx);
|
|
1342
2102
|
}
|
|
1343
2103
|
if (result instanceof Promise) {
|
|
1344
|
-
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));
|
|
1345
2105
|
}
|
|
1346
|
-
return fusedRespond(result, ctx);
|
|
2106
|
+
return fusedRespond(result, ctx, this.responseBodyTag, this.staticResponseHeaders);
|
|
1347
2107
|
};
|
|
1348
2108
|
return (source, params, search, signal, budget, platform, nativeContext) => {
|
|
1349
2109
|
const ctx = nativeContext
|
|
1350
|
-
? RequestContext.native(source, params, this.maxBodyBytes)
|
|
2110
|
+
? RequestContext.native(source, params, search, this.maxBodyBytes, platform)
|
|
1351
2111
|
: new RequestContext(source, params, search, signal, budget, platform, this.maxBodyBytes);
|
|
1352
2112
|
if (decorations !== undefined)
|
|
1353
2113
|
Object.assign(ctx, decorations);
|
|
@@ -1360,11 +2120,11 @@ export class Server {
|
|
|
1360
2120
|
}
|
|
1361
2121
|
if (validation instanceof Promise) {
|
|
1362
2122
|
return validation.then((settled) => settled.issues !== undefined
|
|
1363
|
-
? validationError(settled.issues)
|
|
2123
|
+
? this.wrapWebResponse(validationError(settled.issues))
|
|
1364
2124
|
: runHandler(ctx, settled.value), (err) => logError(err, ctx));
|
|
1365
2125
|
}
|
|
1366
2126
|
if (validation.issues !== undefined)
|
|
1367
|
-
return validationError(validation.issues);
|
|
2127
|
+
return this.wrapWebResponse(validationError(validation.issues));
|
|
1368
2128
|
return runHandler(ctx, validation.value);
|
|
1369
2129
|
};
|
|
1370
2130
|
}
|
|
@@ -1402,25 +2162,20 @@ export class Server {
|
|
|
1402
2162
|
return dispatch(0);
|
|
1403
2163
|
}
|
|
1404
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) {
|
|
1405
2170
|
const contentType = headerOf(source, "content-type") ?? "";
|
|
1406
2171
|
if (contentType !== "application/json" && !contentType.includes("application/json")) {
|
|
1407
2172
|
if (isUrlEncodedForm(contentType)) {
|
|
1408
|
-
|
|
1409
|
-
// Off the JSON inline lane on purpose: forms are a fraction of API traffic, and the
|
|
1410
|
-
// bounded form reader shares the streaming guard. JSON requests are untouched.
|
|
1411
|
-
return readBoundedForm(source, this.maxBodyBytes).then((form) => {
|
|
1412
|
-
if (form instanceof Response)
|
|
1413
|
-
return wrapResponse(form);
|
|
1414
|
-
return this.finishBodyOnly(entry, form, ctx, finalize, wrapResponse);
|
|
1415
|
-
});
|
|
2173
|
+
return readBoundedForm(source, this.maxBodyBytes).then((form) => (form instanceof Response ? wrapResponse(form) : onParsed(form)), onError);
|
|
1416
2174
|
}
|
|
1417
2175
|
return Promise.resolve(wrapResponse(jsonError(415, "unsupported_media_type")));
|
|
1418
2176
|
}
|
|
1419
|
-
//
|
|
1420
|
-
//
|
|
1421
|
-
// `readBoundedJson` (an extra async-fn frame + per-request `finish`/`applyValidation`
|
|
1422
|
-
// closures) is kept for the chunked / length-less / oversized cases it exists for.
|
|
1423
|
-
// 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.
|
|
1424
2179
|
const declared = headerOf(source, "content-length");
|
|
1425
2180
|
if (declared !== null) {
|
|
1426
2181
|
const length = parseContentLength(declared);
|
|
@@ -1431,21 +2186,14 @@ export class Server {
|
|
|
1431
2186
|
return Promise.resolve(wrapResponse(jsonError(413, "payload_too_large")));
|
|
1432
2187
|
}
|
|
1433
2188
|
if (headerOf(source, "transfer-encoding") === null) {
|
|
1434
|
-
return source.json().then((
|
|
1435
|
-
// Native json() rejection = malformed JSON; anything past parse goes through
|
|
1436
|
-
// finishBodyOnly's own error handling.
|
|
1437
|
-
() => wrapResponse(jsonError(400, "invalid_json")));
|
|
2189
|
+
return source.json().then(onParsed, () => wrapResponse(jsonError(400, "invalid_json")));
|
|
1438
2190
|
}
|
|
1439
2191
|
}
|
|
1440
2192
|
try {
|
|
1441
|
-
return this.readBoundedJson(source).then((parsed) =>
|
|
1442
|
-
if (parsed instanceof Response)
|
|
1443
|
-
return wrapResponse(parsed);
|
|
1444
|
-
return this.finishBodyOnly(entry, parsed, ctx, finalize, wrapResponse);
|
|
1445
|
-
}, (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse));
|
|
2193
|
+
return this.readBoundedJson(source).then((parsed) => (parsed instanceof Response ? wrapResponse(parsed) : onParsed(parsed)), onError);
|
|
1446
2194
|
}
|
|
1447
2195
|
catch (err) {
|
|
1448
|
-
return
|
|
2196
|
+
return Promise.resolve(onError(err));
|
|
1449
2197
|
}
|
|
1450
2198
|
}
|
|
1451
2199
|
/** Validate + run the handler for the bodyOnly path - shared by the inline fast path and the
|
|
@@ -1626,7 +2374,11 @@ export class Server {
|
|
|
1626
2374
|
throw error;
|
|
1627
2375
|
});
|
|
1628
2376
|
}
|
|
1629
|
-
|
|
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);
|
|
1630
2382
|
}
|
|
1631
2383
|
/** Notify terminal observers in order while isolating both sync and async failures. */
|
|
1632
2384
|
notifyResponseFinalized(outcome, req) {
|
|
@@ -1689,100 +2441,314 @@ export class Server {
|
|
|
1689
2441
|
* Generic over the render: a success value goes through `finalize(result, set)` (a Web `Response`, or
|
|
1690
2442
|
* node-direct primitives); an early/error `Response` (validation, thrown, 500) through `wrapResponse`.
|
|
1691
2443
|
*/
|
|
1692
|
-
|
|
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
|
+
}
|
|
1693
2575
|
try {
|
|
1694
|
-
if (entry.schema?.params !== undefined) {
|
|
1695
|
-
// Path params arrive as strings from routing; validate (and coerce) them at the boundary, before
|
|
1696
|
-
// the body/query, so a malformed `:id` is a 422 before any work runs. Raw `.validate` (no wrapper
|
|
1697
|
-
// alloc), same recovery-hook path as body/query.
|
|
1698
|
-
const validation = entry.schema.params["~standard"].validate(ctx.params);
|
|
1699
|
-
const result = validation instanceof Promise ? await validation : validation;
|
|
1700
|
-
const paramsError = await this.applyLifecycleValidation(entry, result, ctx, "params");
|
|
1701
|
-
if (paramsError !== undefined)
|
|
1702
|
-
return wrapResponse(paramsError);
|
|
1703
|
-
}
|
|
1704
|
-
if (entry.schema?.body !== undefined) {
|
|
1705
|
-
const bodyError = await this.readAndValidateBody(source, entry, ctx);
|
|
1706
|
-
if (bodyError !== undefined)
|
|
1707
|
-
return wrapResponse(bodyError);
|
|
1708
|
-
}
|
|
1709
|
-
if (entry.schema?.query !== undefined) {
|
|
1710
|
-
// Repeated query keys promote to `string[]` (so an array schema validates `?a=1&a=2`); a
|
|
1711
|
-
// single occurrence stays a string. Raw `.validate` (read `.issues`/`.value`) skips the
|
|
1712
|
-
// validateStandard wrapper alloc.
|
|
1713
|
-
const validation = entry.schema.query["~standard"].validate(queryObjectOf(ctx[CONTEXT_SEARCH]));
|
|
1714
|
-
const result = validation instanceof Promise ? await validation : validation;
|
|
1715
|
-
const queryError = await this.applyLifecycleValidation(entry, result, ctx, "query");
|
|
1716
|
-
if (queryError !== undefined)
|
|
1717
|
-
return wrapResponse(queryError);
|
|
1718
|
-
}
|
|
1719
|
-
// Context extensions: static decorations, then per-request derives.
|
|
1720
|
-
// Each hook below only awaits when it actually returns a Promise - a sync hook
|
|
1721
|
-
// skips the microtask tick (a fast-path; ~64 ns/hook).
|
|
1722
2576
|
if (entry.hasDecorations)
|
|
1723
|
-
Object.assign(ctx, entry.decorations);
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
for (const derive of entry.derives) {
|
|
1729
|
-
const extension = derive(ctx);
|
|
1730
|
-
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);
|
|
1731
2582
|
}
|
|
2583
|
+
Object.assign(ctx, extension);
|
|
1732
2584
|
}
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
const early = outcome instanceof Promise ? await outcome : outcome;
|
|
1738
|
-
if (early !== undefined)
|
|
1739
|
-
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);
|
|
1740
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);
|
|
1741
2614
|
}
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
if (
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
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);
|
|
1749
2697
|
}
|
|
2698
|
+
result = transformed;
|
|
1750
2699
|
}
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
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 {
|
|
1754
2721
|
const contract = entry.responseContract;
|
|
1755
|
-
if (contract
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
// caller's. Fail closed: the undeclared payload is never sent, and the detail goes to the
|
|
1761
|
-
// (redacting) logger rather than to the client.
|
|
1762
|
-
this.logger.error("response contract violation", {
|
|
1763
|
-
method: ctx.req.method,
|
|
1764
|
-
path: pathnameOf(ctx.req.url),
|
|
1765
|
-
// `detail`, not `message`: the logger uses `message` for its own first argument, so a
|
|
1766
|
-
// field of that name is silently overwritten and the diagnosis disappears.
|
|
1767
|
-
detail: outcome.message,
|
|
1768
|
-
});
|
|
1769
|
-
return wrapResponse(jsonError(500, "internal_error"));
|
|
1770
|
-
}
|
|
1771
|
-
if (outcome.kind === "warn") {
|
|
1772
|
-
this.logger.warn("response contract", {
|
|
1773
|
-
method: ctx.req.method,
|
|
1774
|
-
path: pathnameOf(ctx.req.url),
|
|
1775
|
-
detail: outcome.message,
|
|
1776
|
-
});
|
|
1777
|
-
}
|
|
1778
|
-
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));
|
|
1779
2727
|
}
|
|
1780
|
-
return
|
|
2728
|
+
return this.finishContractOutcome(ctx, finalize, wrapResponse, checked);
|
|
1781
2729
|
}
|
|
1782
2730
|
catch (err) {
|
|
1783
2731
|
return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse);
|
|
1784
2732
|
}
|
|
1785
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
|
+
}
|
|
1786
2752
|
async handleLifecycleError(entry, err, ctx, finalize, wrapResponse) {
|
|
1787
2753
|
// A *thrown* Response is deliberate control flow, not an error - a guard throws a redirect/401,
|
|
1788
2754
|
// an action throws an error page. Return it as-is (Remix/SvelteKit semantics); don't run onError
|
|
@@ -1842,7 +2808,7 @@ export class Server {
|
|
|
1842
2808
|
}
|
|
1843
2809
|
/** Apply validation and its recovery hook on the generic lifecycle lane. Recovery is completed
|
|
1844
2810
|
* before derives/beforeHandle run, matching the body-only and query-only execution lanes. */
|
|
1845
|
-
|
|
2811
|
+
applyLifecycleValidation(entry, result, ctx, kind) {
|
|
1846
2812
|
const assign = (value) => {
|
|
1847
2813
|
if (kind === "body")
|
|
1848
2814
|
ctx.body = value;
|
|
@@ -1859,9 +2825,14 @@ export class Server {
|
|
|
1859
2825
|
if (hook === undefined)
|
|
1860
2826
|
return validationError(result.issues);
|
|
1861
2827
|
const attempted = hook(result.issues, ctx, kind);
|
|
1862
|
-
|
|
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) {
|
|
1863
2834
|
if (recovery === undefined)
|
|
1864
|
-
return validationError(
|
|
2835
|
+
return validationError(issues);
|
|
1865
2836
|
if (recovery instanceof Response)
|
|
1866
2837
|
return recovery;
|
|
1867
2838
|
const schema = kind === "body"
|
|
@@ -1870,10 +2841,17 @@ export class Server {
|
|
|
1870
2841
|
? entry.schema?.query
|
|
1871
2842
|
: entry.schema?.params;
|
|
1872
2843
|
const retried = schema["~standard"].validate(recovery);
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
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);
|
|
1877
2855
|
return undefined;
|
|
1878
2856
|
}
|
|
1879
2857
|
/**
|
|
@@ -1971,7 +2949,18 @@ export class Server {
|
|
|
1971
2949
|
methods[method] = this.compileBunNativeHandler(entry, paramNames, fused, unboundedSignal, unboundedBudget);
|
|
1972
2950
|
count += 1;
|
|
1973
2951
|
}
|
|
1974
|
-
|
|
2952
|
+
if (count === 0)
|
|
2953
|
+
return undefined;
|
|
2954
|
+
// RFC 9110 §9.3.2: a GET route answers HEAD with identical status + headers (Bun strips the
|
|
2955
|
+
// body on the wire). Alias the compiled GET handler under HEAD so HEAD stays on the native
|
|
2956
|
+
// lane instead of falling through to the portable dispatcher; the router applies the same
|
|
2957
|
+
// fallback there, so both lanes agree. An explicit HEAD registration wins via the ??=.
|
|
2958
|
+
for (const path of Object.keys(routes)) {
|
|
2959
|
+
const methods = routes[path];
|
|
2960
|
+
if (methods.GET !== undefined)
|
|
2961
|
+
methods.HEAD ??= methods.GET;
|
|
2962
|
+
}
|
|
2963
|
+
return routes;
|
|
1975
2964
|
}
|
|
1976
2965
|
/**
|
|
1977
2966
|
* Start a `Bun.serve` instance bound to `port` (use `0` for an ephemeral port).
|