@nifrajs/core 2.14.1 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -0
- package/dist/assurance.d.ts +16 -8
- package/dist/assurance.d.ts.map +1 -1
- package/dist/assurance.js +33 -0
- package/dist/assurance.js.map +1 -1
- package/dist/edge-kit.d.ts +27 -0
- package/dist/edge-kit.d.ts.map +1 -0
- package/dist/edge-kit.js +24 -0
- package/dist/edge-kit.js.map +1 -0
- package/dist/errors.d.ts +1 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/internal/route-execution.d.ts +11 -5
- package/dist/internal/route-execution.d.ts.map +1 -1
- package/dist/internal/route-execution.js +2 -1
- package/dist/internal/route-execution.js.map +1 -1
- package/dist/response-observer.d.ts +25 -0
- package/dist/response-observer.d.ts.map +1 -0
- package/dist/response-observer.js +40 -0
- package/dist/response-observer.js.map +1 -0
- package/dist/security/baseline.d.ts +68 -0
- package/dist/security/baseline.d.ts.map +1 -0
- package/dist/security/baseline.js +118 -0
- package/dist/security/baseline.js.map +1 -0
- package/dist/server/bare-error-lane.d.ts +26 -0
- package/dist/server/bare-error-lane.d.ts.map +1 -0
- package/dist/server/bare-error-lane.js +60 -0
- package/dist/server/bare-error-lane.js.map +1 -0
- package/dist/server/body.d.ts +7 -0
- package/dist/server/body.d.ts.map +1 -1
- package/dist/server/body.js +7 -0
- package/dist/server/body.js.map +1 -1
- package/dist/server/bridge-markers.d.ts +25 -0
- package/dist/server/bridge-markers.d.ts.map +1 -0
- package/dist/server/bridge-markers.js +25 -0
- package/dist/server/bridge-markers.js.map +1 -0
- package/dist/server/context.d.ts +6 -1
- package/dist/server/context.d.ts.map +1 -1
- package/dist/server/header-case.d.ts.map +1 -1
- package/dist/server/header-case.js +2 -1
- package/dist/server/header-case.js.map +1 -1
- package/dist/server/http.d.ts +36 -3
- package/dist/server/http.d.ts.map +1 -1
- package/dist/server/http.js +49 -3
- package/dist/server/http.js.map +1 -1
- package/dist/server/install.d.ts +6 -8
- package/dist/server/install.d.ts.map +1 -1
- package/dist/server/install.js +7 -0
- package/dist/server/install.js.map +1 -1
- package/dist/server/node-outcome-hook.d.ts +2 -1
- package/dist/server/node-outcome-hook.d.ts.map +1 -1
- package/dist/server/node-outcome-hook.js.map +1 -1
- package/dist/server/node-outcome.d.ts +17 -2
- package/dist/server/node-outcome.d.ts.map +1 -1
- package/dist/server/node-outcome.js +37 -3
- package/dist/server/node-outcome.js.map +1 -1
- package/dist/server/query.d.ts +7 -1
- package/dist/server/query.d.ts.map +1 -1
- package/dist/server/query.js +8 -3
- package/dist/server/query.js.map +1 -1
- package/dist/server/request-context.d.ts +17 -4
- package/dist/server/request-context.d.ts.map +1 -1
- package/dist/server/request-context.js +63 -29
- package/dist/server/request-context.js.map +1 -1
- package/dist/server/respond.d.ts.map +1 -1
- package/dist/server/respond.js +19 -2
- package/dist/server/respond.js.map +1 -1
- package/dist/server/response-contract-lane.d.ts.map +1 -1
- package/dist/server/response-contract-lane.js +5 -2
- package/dist/server/response-contract-lane.js.map +1 -1
- package/dist/server/response-observer-runtime.d.ts +20 -0
- package/dist/server/response-observer-runtime.d.ts.map +1 -0
- package/dist/server/response-observer-runtime.js +95 -0
- package/dist/server/response-observer-runtime.js.map +1 -0
- package/dist/server/runtime-core.d.ts +60 -0
- package/dist/server/runtime-core.d.ts.map +1 -1
- package/dist/server/runtime-core.js +65 -1
- package/dist/server/runtime-core.js.map +1 -1
- package/dist/server/server-types.d.ts +3 -1
- package/dist/server/server-types.d.ts.map +1 -1
- package/dist/server/server.d.ts +52 -39
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +267 -271
- package/dist/server/server.js.map +1 -1
- package/dist/server/websocket.d.ts +20 -4
- package/dist/server/websocket.d.ts.map +1 -1
- package/dist/server/websocket.js +81 -11
- package/dist/server/websocket.js.map +1 -1
- package/dist/server/ws-bun.d.ts +1 -1
- package/dist/server/ws-bun.d.ts.map +1 -1
- package/dist/server/ws-bun.js +12 -8
- package/dist/server/ws-bun.js.map +1 -1
- package/dist/server.d.ts +2 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +2 -1
- package/dist/server.js.map +1 -1
- package/dist/single-copy-register.d.ts +2 -0
- package/dist/single-copy-register.d.ts.map +1 -0
- package/dist/single-copy-register.js +24 -0
- package/dist/single-copy-register.js.map +1 -0
- package/dist/single-copy.d.ts +156 -0
- package/dist/single-copy.d.ts.map +1 -0
- package/dist/single-copy.js +470 -0
- package/dist/single-copy.js.map +1 -0
- package/package.json +24 -2
- package/src/assurance.ts +58 -1
- package/src/edge-kit.ts +27 -0
- package/src/errors.ts +1 -0
- package/src/index.ts +1 -1
- package/src/internal/route-execution.ts +23 -16
- package/src/response-observer.ts +64 -0
- package/src/security/baseline.ts +172 -0
- package/src/server/bare-error-lane.ts +76 -0
- package/src/server/body.ts +8 -0
- package/src/server/bridge-markers.ts +24 -0
- package/src/server/context.ts +6 -1
- package/src/server/header-case.ts +3 -1
- package/src/server/http.ts +53 -3
- package/src/server/install.ts +14 -0
- package/src/server/node-outcome-hook.ts +2 -1
- package/src/server/node-outcome.ts +44 -3
- package/src/server/query.ts +10 -4
- package/src/server/request-context.ts +85 -33
- package/src/server/respond.ts +26 -1
- package/src/server/response-contract-lane.ts +6 -2
- package/src/server/response-observer-runtime.ts +135 -0
- package/src/server/runtime-core.ts +98 -1
- package/src/server/server-types.ts +3 -1
- package/src/server/server.ts +436 -367
- package/src/server/websocket.ts +127 -17
- package/src/server/ws-bun.ts +26 -7
- package/src/server.ts +2 -1
- package/src/single-copy-register.ts +24 -0
- package/src/single-copy.ts +524 -0
package/dist/server/server.js
CHANGED
|
@@ -7,107 +7,19 @@ import { compileRouteExecutionPlan, } from "../internal/route-execution.js";
|
|
|
7
7
|
import { isSameOriginRequest } from "../internal/same-origin.js";
|
|
8
8
|
import { compileRoutePattern, decodeRouteParams } from "../router/pattern.js";
|
|
9
9
|
import { EMPTY_PARAMS, Router } from "../router/router.js";
|
|
10
|
-
import {
|
|
10
|
+
import { emitRequestErrorLog, renderBareError } from "./bare-error-lane.js";
|
|
11
|
+
import { assertByteLimit, markTransportCap, markTrustedBodyFraming, UNLIMITED_BODY_BYTES, } from "./body.js";
|
|
11
12
|
import { resolveClientIp } from "./client-ip.js";
|
|
12
13
|
import { hasLowercaseHeaderKeysMark, headerKeysAllLowercase, markLowercaseHeaderKeys, } from "./header-case.js";
|
|
13
14
|
import { headerObjectOf } from "./headers.js";
|
|
14
|
-
import { jsonError, pathnameOf, urlPartsOf } from "./http.js";
|
|
15
|
+
import { jsonError, pathnameOf, plainError, urlPartsOf } from "./http.js";
|
|
15
16
|
import { withStaticNodeHeaders } from "./node-outcome.js";
|
|
16
|
-
import { recordHeadersView, } from "./node-outcome-hook.js";
|
|
17
17
|
import { isUrlEncodedForm, queryObjectOf, readBoundedForm, searchOf, } from "./query.js";
|
|
18
|
-
import { RequestContext, readBoundedJsonSource } from "./request-context.js";
|
|
19
|
-
import { applyStaticResponseHeaders, buildStaticResponseHeaders, fusedRespond, fusedRespondNoSet,
|
|
20
|
-
import {
|
|
21
|
-
import { CONTEXT_SEARCH, CONTEXT_SET, EMPTY_RESPONSE_CONTROLS, getNeverAbortSignal, getUnboundedRequestBudget, headerOf, requestOf, } from "./runtime-core.js";
|
|
18
|
+
import { RequestContext, readBodyFramed, readBoundedJsonSource } from "./request-context.js";
|
|
19
|
+
import { applyStaticResponseHeaders, buildStaticResponseHeaders, fusedRespond, fusedRespondNoSet, toResponse, } from "./respond.js";
|
|
20
|
+
import { CONTEXT_SEARCH, CONTEXT_SET, EMPTY_RESPONSE_CONTROLS, getNeverAbortSignal, getUnboundedRequestBudget, headerOf, isResponseResult, requestOf, status, } from "./runtime-core.js";
|
|
22
21
|
import { normalizeStaticResponseHeaders } from "./static-headers.js";
|
|
23
|
-
|
|
24
|
-
const GUARDED_RESPONSE_HEADERS = new WeakSet();
|
|
25
|
-
/**
|
|
26
|
-
* Detect a guarded Web Headers object before invoking user code. Retrying after catching any
|
|
27
|
-
* TypeError is observably wrong: user hooks are allowed to throw TypeError themselves, and a retry
|
|
28
|
-
* runs those hooks twice. The hot path never reaches the probe: every framework-constructed
|
|
29
|
-
* Response stamps its headers as known-mutable at construction (see respond.ts), so only a
|
|
30
|
-
* handler-returned foreign `Response` - the case that can actually be guarded - pays it, once per
|
|
31
|
-
* headers object. The probe uses a valid private header name and restores a pre-existing value, so
|
|
32
|
-
* the fallback is limited to the actual immutable/guarded-header case.
|
|
33
|
-
*/
|
|
34
|
-
function hasMutableResponseHeaders(headers) {
|
|
35
|
-
if (knownMutableHeaders(headers))
|
|
36
|
-
return true;
|
|
37
|
-
if (GUARDED_RESPONSE_HEADERS.has(headers))
|
|
38
|
-
return false;
|
|
39
|
-
let previous = null;
|
|
40
|
-
try {
|
|
41
|
-
previous = headers.get(HEADER_MUTABILITY_PROBE);
|
|
42
|
-
headers.set(HEADER_MUTABILITY_PROBE, "1");
|
|
43
|
-
if (previous === null)
|
|
44
|
-
headers.delete(HEADER_MUTABILITY_PROBE);
|
|
45
|
-
else
|
|
46
|
-
headers.set(HEADER_MUTABILITY_PROBE, previous);
|
|
47
|
-
rememberMutableHeaders(headers);
|
|
48
|
-
return true;
|
|
49
|
-
}
|
|
50
|
-
catch {
|
|
51
|
-
try {
|
|
52
|
-
if (previous === null)
|
|
53
|
-
headers.delete(HEADER_MUTABILITY_PROBE);
|
|
54
|
-
else
|
|
55
|
-
headers.set(HEADER_MUTABILITY_PROBE, previous);
|
|
56
|
-
}
|
|
57
|
-
catch {
|
|
58
|
-
// The guarded object rejected the cleanup too; the clone below is authoritative.
|
|
59
|
-
}
|
|
60
|
-
GUARDED_RESPONSE_HEADERS.add(headers);
|
|
61
|
-
return false;
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
/** Adapt a portable {@link ResponseBodyHook} into the Web `onResponse` walk. Only a Response
|
|
65
|
-
* carrying the framework-buffered body tag participates - a raw or streamed Response is skipped by
|
|
66
|
-
* contract, never drained. */
|
|
67
|
-
function webResponseBodyHook(fn, owners) {
|
|
68
|
-
return (response, req) => {
|
|
69
|
-
const body = taggedResponseBody(response, owners);
|
|
70
|
-
if (body === undefined)
|
|
71
|
-
return response;
|
|
72
|
-
const out = fn(body, response.headers, webRequestView(req), response.status);
|
|
73
|
-
if (out instanceof Promise)
|
|
74
|
-
return out.then((replaced) => withReplacedBody(response, replaced));
|
|
75
|
-
return withReplacedBody(response, out);
|
|
76
|
-
};
|
|
77
|
-
}
|
|
78
|
-
/** Minimal request view over a Web `Request` for portable header hooks on the Web serving paths. */
|
|
79
|
-
function webRequestView(req) {
|
|
80
|
-
return { method: req.method, url: req.url, header: (name) => req.headers.get(name) };
|
|
81
|
-
}
|
|
82
|
-
/**
|
|
83
|
-
* Adapt a portable {@link ResponseHeadersHook} into the Web `onResponse` walk: run it against the
|
|
84
|
-
* response's own `Headers` in place (no clone). A response whose headers are GUARDED (a raw
|
|
85
|
-
* `fetch()`ed Response returned by a handler) throws on the FIRST mutation, so nothing was applied
|
|
86
|
-
* yet - rerun once against a mutable copy. Async hooks should perform their mutations before their
|
|
87
|
-
* first await for the guard fallback to cover them.
|
|
88
|
-
*/
|
|
89
|
-
function webResponseHeadersHook(fn) {
|
|
90
|
-
return (response, req) => {
|
|
91
|
-
const view = webRequestView(req);
|
|
92
|
-
if (!hasMutableResponseHeaders(response.headers)) {
|
|
93
|
-
const clone = new Response(response.body, response);
|
|
94
|
-
const out = fn(clone.headers, view, clone.status);
|
|
95
|
-
return out instanceof Promise ? out.then(() => clone) : clone;
|
|
96
|
-
}
|
|
97
|
-
const out = fn(response.headers, view, response.status);
|
|
98
|
-
return out instanceof Promise ? out.then(() => response) : response;
|
|
99
|
-
};
|
|
100
|
-
}
|
|
101
|
-
/** Adapt a raw-response fallback hook. Tagged framework payloads already ran through the body tier,
|
|
102
|
-
* while untagged Responses include streams, proxied fetches, and framework-generated error responses. */
|
|
103
|
-
function webResponseRawHook(fn, owners) {
|
|
104
|
-
return (response, req) => {
|
|
105
|
-
if (taggedResponseBody(response, owners) !== undefined)
|
|
106
|
-
return response;
|
|
107
|
-
return fn(response, req);
|
|
108
|
-
};
|
|
109
|
-
}
|
|
110
|
-
import { INSTALL_EFFECT_LEDGER, INSTALL_IDEMPOTENCY, INSTALL_MCP, INSTALL_NODE_DIRECT, INSTALL_RESPONSE_CONTRACT, INSTALL_SSE, INSTALL_WS, } from "./install.js";
|
|
22
|
+
import { INSTALL_EFFECT_LEDGER, INSTALL_IDEMPOTENCY, INSTALL_MCP, INSTALL_NODE_DIRECT, INSTALL_RESPONSE_CONTRACT, INSTALL_RESPONSE_OBSERVER, INSTALL_SSE, INSTALL_WS, NODE_NATIVE_MOUNT, RESOLVE_NODE_MOUNT, } from "./install.js";
|
|
111
23
|
import { jsonLogger } from "./logger.js";
|
|
112
24
|
const functionToString = Function.prototype.toString;
|
|
113
25
|
const CONTEXTLESS_ARROW = /^(?:async\s*)?\(\s*\)\s*(?::[\s\S]*?)?=>/;
|
|
@@ -162,12 +74,18 @@ const STOP_HOOK_TIMEOUT_MS = 5_000;
|
|
|
162
74
|
* owner, shared with the server-function mount in `@nifrajs/web` - the two used to answer differently
|
|
163
75
|
* for the same request, so a browser that could open a socket was told its POST was cross-origin. */
|
|
164
76
|
const wsSameOrigin = isSameOriginRequest;
|
|
165
|
-
function
|
|
77
|
+
function validationIssues(issues) {
|
|
166
78
|
const serialized = issues.map((issue) => {
|
|
167
79
|
const path = issue.path?.map((seg) => String(typeof seg === "object" ? seg.key : seg));
|
|
168
80
|
return path !== undefined ? { message: issue.message, path } : { message: issue.message };
|
|
169
81
|
});
|
|
170
|
-
return
|
|
82
|
+
return { ok: false, error: "validation", issues: serialized };
|
|
83
|
+
}
|
|
84
|
+
/** The 422 as plain data - the shape every lane's response wrapper takes, so a rejected body costs
|
|
85
|
+
* what an accepted one costs: on Node it is written straight to the socket with a `content-length`
|
|
86
|
+
* instead of being built as a `Response` and drained back out. */
|
|
87
|
+
export function plainValidationError(issues) {
|
|
88
|
+
return status(422, validationIssues(issues));
|
|
171
89
|
}
|
|
172
90
|
// `jsonError`, `urlPartsOf`, `pathnameOf` moved to `./http.ts` (a dependency-free leaf shared with the
|
|
173
91
|
// opt-in request lanes); re-exported so existing importers keep resolving from here.
|
|
@@ -210,6 +128,20 @@ function stripMountPrefix(request, prefix) {
|
|
|
210
128
|
function responseSet(ctx) {
|
|
211
129
|
return ctx[CONTEXT_SET]() ?? EMPTY_RESPONSE_CONTROLS;
|
|
212
130
|
}
|
|
131
|
+
/**
|
|
132
|
+
* A `derive` that answered with a response rather than a context extension - `status(...)`, or a bare
|
|
133
|
+
* `Response`. The request ends there, on the same lane a `beforeHandle`'s early return takes.
|
|
134
|
+
*
|
|
135
|
+
* `derive` is the one stage that could not exit early: `beforeHandle` says "done" by returning a
|
|
136
|
+
* value, but a derive's return value IS the extension, so the only way out was `throw`ing a
|
|
137
|
+
* `Response` - which measured as the single most expensive way to answer (the `Response` object
|
|
138
|
+
* costs ~40% of the rejection lane; see `PlainRender`). Neither shape had a meaning here before:
|
|
139
|
+
* `Object.assign(ctx, response)` copies nothing (a `Response`'s properties live on its prototype),
|
|
140
|
+
* so a derive that returned one silently fell through to the handler it meant to prevent.
|
|
141
|
+
*/
|
|
142
|
+
function isDeriveEarlyExit(extension) {
|
|
143
|
+
return isResponseResult(extension) || extension instanceof Response;
|
|
144
|
+
}
|
|
213
145
|
function isContextlessNoArgArrow(handler) {
|
|
214
146
|
if (handler.length !== 0)
|
|
215
147
|
return false;
|
|
@@ -220,8 +152,15 @@ function isContextlessNoArgArrow(handler) {
|
|
|
220
152
|
return false;
|
|
221
153
|
}
|
|
222
154
|
}
|
|
155
|
+
/**
|
|
156
|
+
* The Web lanes' `wrapResponse` input, as a `Response`. A plain-data carrier ({@link plainError},
|
|
157
|
+
* `status(...)`) is rendered on the ordinary JSON lane rather than through its own `toResponse`, so
|
|
158
|
+
* it picks up the same prebuilt init a handler's plain return uses. `EMPTY_RESPONSE_CONTROLS` because
|
|
159
|
+
* these renders happen where no request context exists - before routing, or after it was abandoned.
|
|
160
|
+
*/
|
|
161
|
+
const webResponseOf = (result) => result instanceof Response ? result : toResponse(result, EMPTY_RESPONSE_CONTROLS);
|
|
223
162
|
// Stable module-level finalizers so `fetch`/`resolveNode` allocate no per-request closures.
|
|
224
|
-
const IDENTITY_RESPONSE = (response) => response;
|
|
163
|
+
const IDENTITY_RESPONSE = (response) => webResponseOf(response);
|
|
225
164
|
const RESPONSE_TIMEOUT = () => jsonError(503, "request_timeout");
|
|
226
165
|
// The unused-order-scoped-hook audit (recorder, seal check, stack capture, message) is a dev-time
|
|
227
166
|
// diagnostic. Every guard below keeps the INLINE literal `process.env.NODE_ENV !== "production"` at the
|
|
@@ -525,6 +464,11 @@ export class Server {
|
|
|
525
464
|
* declared *after* this call, and reaches none declared before. One added after the last route
|
|
526
465
|
* (e.g. on the app returned by a route-registering factory) covers nothing - see
|
|
527
466
|
* {@link ServerOptions.unusedScopedHooks}.
|
|
467
|
+
*
|
|
468
|
+
* Returning a `status(...)` (or a `Response`) instead of an extension ends the request there - the
|
|
469
|
+
* guard shape, and the cheap one: it renders as plain data on the same lane a handler's return
|
|
470
|
+
* takes, where `throw new Response(...)` pays ~40% of the rejection lane to build the object. That
|
|
471
|
+
* branch is control flow, so it does not widen the derived context type.
|
|
528
472
|
*/
|
|
529
473
|
derive(fn) {
|
|
530
474
|
this.assertConfigurable("derive()");
|
|
@@ -671,19 +615,33 @@ export class Server {
|
|
|
671
615
|
* hook owns part of the header state - into a hook that runs in the right order. */
|
|
672
616
|
addStaticResponseHeaders(record) {
|
|
673
617
|
if (this.onResponseHooks.length > 0) {
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
618
|
+
const observerHeaders = this.onResponseHeaders;
|
|
619
|
+
if (observerHeaders !== undefined) {
|
|
620
|
+
observerHeaders.call(this, (headers) => {
|
|
621
|
+
for (const name of Object.keys(record)) {
|
|
622
|
+
if (!headers.has(name))
|
|
623
|
+
headers.set(name, record[name]);
|
|
624
|
+
}
|
|
625
|
+
});
|
|
626
|
+
return this;
|
|
627
|
+
}
|
|
628
|
+
// Route the hook through the same seam the static tier uses. On a framework response (headers
|
|
629
|
+
// stamped mutable) it applies the defaults in place and returns the SAME object, so the
|
|
630
|
+
// serialized-body marker survives for later `onResponseBody`/`onResponseRaw` observers and the
|
|
631
|
+
// Node writer; reconstructing a `Response` here would strip that marker and reclassify the Node
|
|
632
|
+
// outcome from `json` to a generic `response`. A guarded foreign response takes its clone path.
|
|
633
|
+
const statics = buildStaticResponseHeaders(record);
|
|
634
|
+
this.onResponseHooks.push((response) => applyStaticResponseHeaders(response, statics));
|
|
635
|
+
this.onNodeResponseHooks.push(undefined);
|
|
636
|
+
this.nodeResponseHooksComplete = false;
|
|
637
|
+
return this;
|
|
680
638
|
}
|
|
681
639
|
const merged = this.staticResponseHeaders === undefined
|
|
682
640
|
? record
|
|
683
641
|
: { ...this.staticResponseHeaders.record, ...record };
|
|
684
642
|
this.staticResponseHeaders = buildStaticResponseHeaders(merged);
|
|
685
643
|
const statics = this.staticResponseHeaders;
|
|
686
|
-
this.wrapWebResponse = (response) => applyStaticResponseHeaders(response, statics);
|
|
644
|
+
this.wrapWebResponse = (response) => applyStaticResponseHeaders(webResponseOf(response), statics);
|
|
687
645
|
this.webResponseTimeout = () => applyStaticResponseHeaders(RESPONSE_TIMEOUT(), statics);
|
|
688
646
|
return this;
|
|
689
647
|
}
|
|
@@ -694,58 +652,14 @@ export class Server {
|
|
|
694
652
|
}
|
|
695
653
|
return this.responseBodyTag;
|
|
696
654
|
}
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
this.onResponseHooks.push(webResponseRawHook(fn, this.responseBodyOwners));
|
|
706
|
-
this.onNodeResponseHooks.push(() => undefined);
|
|
707
|
-
return this;
|
|
708
|
-
}
|
|
709
|
-
/**
|
|
710
|
-
* Register a PORTABLE header-only response hook - one implementation, fast on every runtime.
|
|
711
|
-
*
|
|
712
|
-
* The hook receives a mutable case-insensitive header view, a minimal request view, and the
|
|
713
|
-
* status. On the Web serving paths it runs inside the normal `onResponse` walk against the
|
|
714
|
-
* response's own `Headers` (mutating in place - no clone). On Node it self-pairs as a native
|
|
715
|
-
* response hook against the outcome's plain header record, so registering one NEVER forces the
|
|
716
|
-
* Node adapter off its direct socket writer the way a full `onResponse(res: Response)` hook does.
|
|
717
|
-
* Prefer this over `onResponse` whenever the middleware only reads/writes headers.
|
|
718
|
-
*/
|
|
719
|
-
onResponseHeaders(fn) {
|
|
720
|
-
this.assertConfigurable("onResponseHeaders()");
|
|
721
|
-
this.onResponseHooks.push(webResponseHeadersHook(fn));
|
|
722
|
-
this.onNodeResponseHooks.push((response, req) => fn(recordHeadersView(response), req, response.status));
|
|
723
|
-
return this;
|
|
724
|
-
}
|
|
725
|
-
/**
|
|
726
|
-
* Register a PORTABLE post-serialization body hook - the payload tier. The hook receives the
|
|
727
|
-
* FINAL framework-serialized bytes (plus the mutable header view and status) and may return
|
|
728
|
-
* replacement bytes; `undefined` keeps the body. On the Node direct writer the bytes come
|
|
729
|
-
* straight off the outcome record; on the Web serving paths they ride the framework-built
|
|
730
|
-
* Response as a tag, so no body stream is ever drained on any runtime. A handler-returned raw
|
|
731
|
-
* `Response` (proxied fetch, SSE, streamed SSR) is skipped by contract - transforming those is
|
|
732
|
-
* what the full `onResponse` hook is for.
|
|
733
|
-
*/
|
|
734
|
-
onResponseBody(fn) {
|
|
735
|
-
this.assertConfigurable("onResponseBody()");
|
|
736
|
-
this.enableResponseBodyTagging();
|
|
737
|
-
this.onResponseHooks.push(webResponseBodyHook(fn, this.responseBodyOwners));
|
|
738
|
-
this.onNodeResponseHooks.push((response, req) => {
|
|
739
|
-
const body = response.body;
|
|
740
|
-
if (body === null)
|
|
741
|
-
return undefined;
|
|
742
|
-
const out = fn(body, recordHeadersView(response), req, response.status);
|
|
743
|
-
if (out instanceof Promise)
|
|
744
|
-
return out.then((replaced) => applyBodyReplacement(response, replaced));
|
|
745
|
-
applyBodyReplacement(response, out);
|
|
746
|
-
return undefined;
|
|
747
|
-
});
|
|
748
|
-
return this;
|
|
655
|
+
responseObserverMethods() {
|
|
656
|
+
const methods = this;
|
|
657
|
+
if (typeof methods.onResponseHeaders !== "function" ||
|
|
658
|
+
typeof methods.onResponseBody !== "function" ||
|
|
659
|
+
typeof methods.onResponseRaw !== "function") {
|
|
660
|
+
throw new TypeError("response observation requires `.use(responseObserver())` or an observer-enabled middleware");
|
|
661
|
+
}
|
|
662
|
+
return methods;
|
|
749
663
|
}
|
|
750
664
|
/** Observe the terminal response after all transformations. Observers are ordered and fail-open. */
|
|
751
665
|
onResponseFinalized(fn) {
|
|
@@ -791,6 +705,15 @@ export class Server {
|
|
|
791
705
|
}
|
|
792
706
|
this.globalAssurance.push(...evidence.filter((item) => item.scope === "global"));
|
|
793
707
|
this.activeAssurance.push(...evidence.filter((item) => item.scope === "subsequent"));
|
|
708
|
+
const responseObserver = arg[INSTALL_RESPONSE_OBSERVER];
|
|
709
|
+
if (responseObserver !== undefined) {
|
|
710
|
+
if (typeof responseObserver !== "object" ||
|
|
711
|
+
responseObserver === null ||
|
|
712
|
+
typeof responseObserver.install !== "function") {
|
|
713
|
+
throw new TypeError("response observer middleware has an invalid runtime");
|
|
714
|
+
}
|
|
715
|
+
this[INSTALL_RESPONSE_OBSERVER](responseObserver);
|
|
716
|
+
}
|
|
794
717
|
if (arg.onRequest !== undefined) {
|
|
795
718
|
this.assertConfigurable("onRequest()");
|
|
796
719
|
this.onRequestHooks.push(arg.onRequest);
|
|
@@ -824,11 +747,11 @@ export class Server {
|
|
|
824
747
|
if (arg.responseHeaders !== undefined)
|
|
825
748
|
this.responseHeaders(arg.responseHeaders);
|
|
826
749
|
if (arg.onResponseHeaders !== undefined)
|
|
827
|
-
this.onResponseHeaders(arg.onResponseHeaders);
|
|
750
|
+
this.responseObserverMethods().onResponseHeaders(arg.onResponseHeaders);
|
|
828
751
|
if (arg.onResponseBody !== undefined)
|
|
829
|
-
this.onResponseBody(arg.onResponseBody);
|
|
752
|
+
this.responseObserverMethods().onResponseBody(arg.onResponseBody);
|
|
830
753
|
if (arg.onResponseRaw !== undefined)
|
|
831
|
-
this.onResponseRaw(arg.onResponseRaw);
|
|
754
|
+
this.responseObserverMethods().onResponseRaw(arg.onResponseRaw);
|
|
832
755
|
if (arg.onResponseFinalized !== undefined)
|
|
833
756
|
this.onResponseFinalized(arg.onResponseFinalized);
|
|
834
757
|
if (arg.onError !== undefined)
|
|
@@ -935,6 +858,9 @@ export class Server {
|
|
|
935
858
|
// Boot-time guard: the WS runtime is a subpath (`@nifrajs/core/ws`) so no-WebSocket apps don't
|
|
936
859
|
// bundle it. Registration is the loud, early failure point - never the first connection.
|
|
937
860
|
const runtime = requireWsRuntime(this.wsRuntime);
|
|
861
|
+
if (handler.validateSend === true && handler.sendSchema === undefined) {
|
|
862
|
+
throw new RouteConfigError("INVALID_WS_SEND_VALIDATION", `route WS ${path}: validateSend requires sendSchema`);
|
|
863
|
+
}
|
|
938
864
|
this.topics ??= runtime.createTopics();
|
|
939
865
|
// A `messageSchema` wraps `message` with validation once, here - every adapter then dispatches
|
|
940
866
|
// already-validated, typed messages (Bun/Deno/Node/Workers) with no per-adapter code.
|
|
@@ -987,6 +913,8 @@ export class Server {
|
|
|
987
913
|
}
|
|
988
914
|
prepareRoute(method, path, schema, handler) {
|
|
989
915
|
const pattern = compileRoutePattern(path);
|
|
916
|
+
// Resolved here, once: `undefined` past this point means the route declared `"unlimited"`, NOT
|
|
917
|
+
// "nothing declared". Readers fall back to UNLIMITED_BODY_BYTES accordingly.
|
|
990
918
|
let bodyLimit = this.maxBodyBytes;
|
|
991
919
|
const invalidBodyLimit = (message) => {
|
|
992
920
|
throw new RouteConfigError("INVALID_BODY_LIMIT", `route ${method} ${path}: ${message}`);
|
|
@@ -999,6 +927,12 @@ export class Server {
|
|
|
999
927
|
schema.bodyLimitReason.trim().length === 0) {
|
|
1000
928
|
invalidBodyLimit('bodyLimit: "unlimited" requires a non-empty bodyLimitReason');
|
|
1001
929
|
}
|
|
930
|
+
if (schema.body !== undefined) {
|
|
931
|
+
invalidBodyLimit('bodyLimit: "unlimited" cannot be used with a body schema; use a finite cap');
|
|
932
|
+
}
|
|
933
|
+
if (schema.idempotency !== undefined) {
|
|
934
|
+
invalidBodyLimit('bodyLimit: "unlimited" cannot be used with idempotency');
|
|
935
|
+
}
|
|
1002
936
|
bodyLimit = undefined;
|
|
1003
937
|
}
|
|
1004
938
|
else if (schema?.bodyLimit !== undefined) {
|
|
@@ -1096,12 +1030,12 @@ export class Server {
|
|
|
1096
1030
|
// handler continuation once. The runner keeps the bounded parser and all error semantics while
|
|
1097
1031
|
// avoiding the generic entry/schema/lifecycle dispatch on Bun, Deno, and Node-direct.
|
|
1098
1032
|
const fusedBodyRunner = fusedBody
|
|
1099
|
-
? this.buildFusedBodyRunner(handler, schema.body, hasDecorations ? routeDecorations : undefined, bodyLimit ??
|
|
1033
|
+
? this.buildFusedBodyRunner(handler, schema.body, hasDecorations ? routeDecorations : undefined, bodyLimit ?? UNLIMITED_BODY_BYTES)
|
|
1100
1034
|
: undefined;
|
|
1101
1035
|
const fusedWeb = bare && this.aroundHooks.length === 0
|
|
1102
|
-
? this.buildFusedWeb(handler, hasDecorations ? routeDecorations : undefined, isContextlessNoArgArrow(handler), bodyLimit ??
|
|
1036
|
+
? this.buildFusedWeb(handler, hasDecorations ? routeDecorations : undefined, isContextlessNoArgArrow(handler), bodyLimit ?? UNLIMITED_BODY_BYTES)
|
|
1103
1037
|
: fusedQuery
|
|
1104
|
-
? this.buildFusedQueryWeb(handler, hasDecorations ? routeDecorations : undefined, schema.query, bodyLimit ??
|
|
1038
|
+
? this.buildFusedQueryWeb(handler, hasDecorations ? routeDecorations : undefined, schema.query, bodyLimit ?? UNLIMITED_BODY_BYTES)
|
|
1105
1039
|
: fusedBody
|
|
1106
1040
|
? this.buildFusedBodyWeb(fusedBodyRunner)
|
|
1107
1041
|
: undefined;
|
|
@@ -1209,7 +1143,18 @@ export class Server {
|
|
|
1209
1143
|
});
|
|
1210
1144
|
routeAssurance.push(Object.freeze(declared));
|
|
1211
1145
|
}
|
|
1212
|
-
|
|
1146
|
+
// Two ways a route proves its body is bounded, both enforced by the core at read time:
|
|
1147
|
+
// `schema.body` (the validated read is bounded) and an explicit finite `schema.bodyLimit` (the
|
|
1148
|
+
// transport cap shadows every reader, including a raw `c.req.body` stream that a Content-Length
|
|
1149
|
+
// middleware cannot bound).
|
|
1150
|
+
// `bodyLimit: "unlimited"` is rejected above for schema and idempotency routes. The remaining
|
|
1151
|
+
// exemption is schema-less streaming/upload work, so it publishes NOTHING: there is no finite
|
|
1152
|
+
// framework byte ceiling for assurance to attest and the application protocol owns that bound.
|
|
1153
|
+
// The server-wide `maxBodyBytes` default is not evidence either. It applies to every route
|
|
1154
|
+
// whether or not anyone thought about it, so publishing it would make the id pass everywhere and
|
|
1155
|
+
// prove nothing; this evidence marks a bound the route chose.
|
|
1156
|
+
const unlimitedBody = schema?.bodyLimit === "unlimited";
|
|
1157
|
+
if (!unlimitedBody && (schema?.body !== undefined || typeof schema?.bodyLimit === "number")) {
|
|
1213
1158
|
routeAssurance.push(Object.freeze({
|
|
1214
1159
|
id: NIFRA_ASSURANCE_IDS.BODY_BOUNDED,
|
|
1215
1160
|
source: "route-schema",
|
|
@@ -1244,6 +1189,29 @@ export class Server {
|
|
|
1244
1189
|
this.assertConfigurable("responseContract()");
|
|
1245
1190
|
this.responseContractRuntime = runtime;
|
|
1246
1191
|
}
|
|
1192
|
+
/** @internal Symbol-keyed install seam for the response observer plugin. */
|
|
1193
|
+
[INSTALL_RESPONSE_OBSERVER](runtime) {
|
|
1194
|
+
this.assertConfigurable("responseObserver()");
|
|
1195
|
+
const methods = runtime.install({
|
|
1196
|
+
assertConfigurable: (operation) => this.assertConfigurable(operation),
|
|
1197
|
+
addResponseHook: (web, node) => {
|
|
1198
|
+
this.onResponseHooks.push(web);
|
|
1199
|
+
this.onNodeResponseHooks.push(node);
|
|
1200
|
+
if (node === undefined)
|
|
1201
|
+
this.nodeResponseHooksComplete = false;
|
|
1202
|
+
},
|
|
1203
|
+
enableResponseBodyTagging: () => {
|
|
1204
|
+
if (this.responseBodyTag === undefined) {
|
|
1205
|
+
this.responseBodyTag = Object.freeze({});
|
|
1206
|
+
this.responseBodyOwners.add(this.responseBodyTag);
|
|
1207
|
+
}
|
|
1208
|
+
return this.responseBodyTag;
|
|
1209
|
+
},
|
|
1210
|
+
responseBodyOwners: () => this.responseBodyOwners,
|
|
1211
|
+
});
|
|
1212
|
+
Object.assign(this, methods);
|
|
1213
|
+
return methods;
|
|
1214
|
+
}
|
|
1247
1215
|
/** @internal Symbol-keyed install seam for the `idempotency()` plugin. Off the public typed surface. */
|
|
1248
1216
|
[INSTALL_IDEMPOTENCY](runtime) {
|
|
1249
1217
|
this.assertConfigurable("idempotency()");
|
|
@@ -1374,13 +1342,13 @@ export class Server {
|
|
|
1374
1342
|
// Rebuild with the SAME builder that produced the closure - rebinding a query-fused route as
|
|
1375
1343
|
// bare would silently drop its validation.
|
|
1376
1344
|
const fusedBody = entry.execution.fusedLane === "body"
|
|
1377
|
-
? this.buildFusedBodyRunner(entry.handler, entry.schema?.body, entry.hasDecorations ? entry.decorations : undefined, entry.bodyLimit ??
|
|
1345
|
+
? this.buildFusedBodyRunner(entry.handler, entry.schema?.body, entry.hasDecorations ? entry.decorations : undefined, entry.bodyLimit ?? UNLIMITED_BODY_BYTES)
|
|
1378
1346
|
: undefined;
|
|
1379
1347
|
const fusedWeb = entry.execution.fusedLane === "body"
|
|
1380
1348
|
? this.buildFusedBodyWeb(fusedBody)
|
|
1381
1349
|
: entry.execution.fusedLane === "query"
|
|
1382
|
-
? this.buildFusedQueryWeb(entry.handler, entry.hasDecorations ? entry.decorations : undefined, entry.schema?.query, entry.bodyLimit ??
|
|
1383
|
-
: this.buildFusedWeb(entry.handler, entry.hasDecorations ? entry.decorations : undefined, isContextlessNoArgArrow(entry.handler), entry.bodyLimit ??
|
|
1350
|
+
? this.buildFusedQueryWeb(entry.handler, entry.hasDecorations ? entry.decorations : undefined, entry.schema?.query, entry.bodyLimit ?? UNLIMITED_BODY_BYTES)
|
|
1351
|
+
: this.buildFusedWeb(entry.handler, entry.hasDecorations ? entry.decorations : undefined, isContextlessNoArgArrow(entry.handler), entry.bodyLimit ?? UNLIMITED_BODY_BYTES);
|
|
1384
1352
|
return {
|
|
1385
1353
|
...route,
|
|
1386
1354
|
entry: {
|
|
@@ -1498,6 +1466,42 @@ export class Server {
|
|
|
1498
1466
|
release();
|
|
1499
1467
|
return outcome;
|
|
1500
1468
|
}
|
|
1469
|
+
/**
|
|
1470
|
+
* Select a mounted handler's native Node lane without materializing a Web Request. This is an
|
|
1471
|
+
* adapter seam, not a second lifecycle: any app-wide behavior that the ordinary mount path would
|
|
1472
|
+
* run makes the capability ineligible and the caller falls back to `resolveNodeSource`. Typed
|
|
1473
|
+
* routes keep precedence exactly as `routeAndRun` does, including the existing mount behavior for
|
|
1474
|
+
* a path whose registered route rejects this method.
|
|
1475
|
+
*/
|
|
1476
|
+
[RESOLVE_NODE_MOUNT](source) {
|
|
1477
|
+
if (this.onRequestHooks.length > 0 ||
|
|
1478
|
+
this.onResponseHooks.length > 0 ||
|
|
1479
|
+
this.onResponseFinalizedHooks.length > 0 ||
|
|
1480
|
+
this.capacityGate !== undefined ||
|
|
1481
|
+
this.staticResponseHeaders !== undefined ||
|
|
1482
|
+
this.requestTimeoutMs !== 0 ||
|
|
1483
|
+
this.acceptInboundDeadlines ||
|
|
1484
|
+
this.clientIpTrust !== undefined) {
|
|
1485
|
+
return undefined;
|
|
1486
|
+
}
|
|
1487
|
+
const parts = source.urlParts ?? urlPartsOf(source.url);
|
|
1488
|
+
const match = this.catalog.find(source.method, parts.pathname);
|
|
1489
|
+
if (match.found)
|
|
1490
|
+
return undefined;
|
|
1491
|
+
for (const mount of this.fetchMounts) {
|
|
1492
|
+
if (!underMountPrefix(parts.pathname, mount.path))
|
|
1493
|
+
continue;
|
|
1494
|
+
const candidate = mount.handler[NODE_NATIVE_MOUNT];
|
|
1495
|
+
return typeof candidate === "function"
|
|
1496
|
+
? {
|
|
1497
|
+
handler: candidate,
|
|
1498
|
+
path: mount.path,
|
|
1499
|
+
stripPrefix: mount.stripPrefix,
|
|
1500
|
+
}
|
|
1501
|
+
: undefined;
|
|
1502
|
+
}
|
|
1503
|
+
return undefined;
|
|
1504
|
+
}
|
|
1501
1505
|
/**
|
|
1502
1506
|
* Like {@link fetch}, but renders a plain-data result **without** building a Web `Response` - the
|
|
1503
1507
|
* `@nifrajs/node` adapter serializes the returned primitives straight to the socket, skipping the undici
|
|
@@ -2037,15 +2041,18 @@ export class Server {
|
|
|
2037
2041
|
: wrapResponse(mounted);
|
|
2038
2042
|
}
|
|
2039
2043
|
if (match.reason === "method-not-allowed") {
|
|
2040
|
-
return wrapResponse(
|
|
2044
|
+
return wrapResponse(
|
|
2045
|
+
// Lowercase on purpose: a plain render's headers go into the node outcome record verbatim,
|
|
2046
|
+
// and every other name in that record is lowercase (the Web lane normalizes either way).
|
|
2047
|
+
plainError(405, "method_not_allowed", { allow: match.allowed.join(", ") }));
|
|
2041
2048
|
}
|
|
2042
|
-
return wrapResponse(
|
|
2049
|
+
return wrapResponse(plainError(404, "not_found"));
|
|
2043
2050
|
}
|
|
2044
2051
|
// Inspect only captured values for escapes. Scanning the full pathname repeated work the router
|
|
2045
2052
|
// already did and made every plain dynamic route pay for unrelated static path bytes.
|
|
2046
2053
|
const params = match.params === EMPTY_PARAMS ? match.params : decodeRouteParams(match.params);
|
|
2047
2054
|
if (params === null) {
|
|
2048
|
-
return wrapResponse(
|
|
2055
|
+
return wrapResponse(plainError(400, "malformed_path"));
|
|
2049
2056
|
}
|
|
2050
2057
|
return this.runMatched(source, platform, match.payload, params, search, finalize, wrapResponse, onTimeout, webFast);
|
|
2051
2058
|
}
|
|
@@ -2080,7 +2087,7 @@ export class Server {
|
|
|
2080
2087
|
// The runtime is always present when a route resolved idempotency (enforced at registration).
|
|
2081
2088
|
if (entry.idempotent !== undefined && this.idempotencyRuntime !== undefined) {
|
|
2082
2089
|
return this.idempotencyRuntime.run(entry.idempotent, requestOf(source), platform, entry, params, search, wrapResponse, {
|
|
2083
|
-
maxBodyBytes: entry.bodyLimit ??
|
|
2090
|
+
maxBodyBytes: entry.bodyLimit ?? UNLIMITED_BODY_BYTES,
|
|
2084
2091
|
runLanes: (buffered, plat, ent, prm, srch) => this.idempotencyRunLanes(buffered, plat, ent, prm, srch),
|
|
2085
2092
|
});
|
|
2086
2093
|
}
|
|
@@ -2106,7 +2113,7 @@ export class Server {
|
|
|
2106
2113
|
? undefined
|
|
2107
2114
|
: admitDeadline(source.headers, this.deadlineAdmissionOptions);
|
|
2108
2115
|
if (admission !== undefined && !admission.ok) {
|
|
2109
|
-
return wrapResponse(
|
|
2116
|
+
return wrapResponse(plainError(admission.status, admission.reason));
|
|
2110
2117
|
}
|
|
2111
2118
|
const effectiveTimeoutMs = admission?.timeoutMs ?? 0;
|
|
2112
2119
|
// Only allocate a controller for a finite budget; the historical no-timeout path remains
|
|
@@ -2131,7 +2138,7 @@ export class Server {
|
|
|
2131
2138
|
// already complete and can't time out, so it's returned as-is (no 503 race, no promise).
|
|
2132
2139
|
if (controller !== undefined && outcome instanceof Promise) {
|
|
2133
2140
|
const timedOut = admission?.inherited === true
|
|
2134
|
-
? () => wrapResponse(
|
|
2141
|
+
? () => wrapResponse(plainError(504, "deadline_exceeded"))
|
|
2135
2142
|
: onTimeout;
|
|
2136
2143
|
return this.withTimeout(outcome, controller, timedOut, Math.max(0, Math.ceil(budget.remaining())));
|
|
2137
2144
|
}
|
|
@@ -2162,9 +2169,13 @@ export class Server {
|
|
|
2162
2169
|
contextlessBareError(err, source, params, search, signal, budget, platform, wrapResponse) {
|
|
2163
2170
|
if (err instanceof Response)
|
|
2164
2171
|
return wrapResponse(err);
|
|
2172
|
+
// Handed over as the plain data it is: `wrapResponse` renders it on the JSON lane (no `Response`
|
|
2173
|
+
// built on Node). There is no context here by construction, so there is no `c.set` to merge.
|
|
2174
|
+
if (isResponseResult(err))
|
|
2175
|
+
return wrapResponse(err);
|
|
2165
2176
|
const ctx = new RequestContext(source, params, search, signal, budget, platform, this.maxBodyBytes, this.protoPoisoning);
|
|
2166
2177
|
this.logRequestError(err, ctx);
|
|
2167
|
-
return wrapResponse(
|
|
2178
|
+
return wrapResponse(plainError(500, "internal_error"));
|
|
2168
2179
|
}
|
|
2169
2180
|
/**
|
|
2170
2181
|
* The synchronous fast path selected by a route's execution plan: apply static decorations, call the
|
|
@@ -2184,11 +2195,11 @@ export class Server {
|
|
|
2184
2195
|
result = entry.handler(ctx);
|
|
2185
2196
|
}
|
|
2186
2197
|
catch (err) {
|
|
2187
|
-
return this.bareError(err, ctx, wrapResponse);
|
|
2198
|
+
return this.bareError(err, ctx, finalize, wrapResponse);
|
|
2188
2199
|
}
|
|
2189
2200
|
if (result instanceof Promise) {
|
|
2190
2201
|
// Async handler on an otherwise-bare route: finish on a microtask, with the same error handling.
|
|
2191
|
-
return result.then((value) => finalize(value, responseSet(ctx)), (err) => this.bareError(err, ctx, wrapResponse));
|
|
2202
|
+
return result.then((value) => finalize(value, responseSet(ctx)), (err) => this.bareError(err, ctx, finalize, wrapResponse));
|
|
2192
2203
|
}
|
|
2193
2204
|
return finalize(result, responseSet(ctx));
|
|
2194
2205
|
}
|
|
@@ -2202,14 +2213,10 @@ export class Server {
|
|
|
2202
2213
|
* 500s), same respond semantics (the lifecycle parity suite pins it).
|
|
2203
2214
|
*/
|
|
2204
2215
|
buildFusedWeb(handler, decorations, contextless, maxBodyBytes) {
|
|
2205
|
-
//
|
|
2206
|
-
//
|
|
2207
|
-
|
|
2208
|
-
|
|
2209
|
-
return this.wrapWebResponse(err);
|
|
2210
|
-
this.logRequestError(err, ctx);
|
|
2211
|
-
return this.wrapWebResponse(jsonError(500, "internal_error"));
|
|
2212
|
-
};
|
|
2216
|
+
// Same thrown-value contract as the generic lane (`bare-error-lane.ts` via `bareError`); the fused
|
|
2217
|
+
// lane only injects its renderer, so a thrown `status(...)` and the flat 500 fold in static headers
|
|
2218
|
+
// through `wrapWebResponse`. One source of truth for the error surface, no per-lane copy to drift.
|
|
2219
|
+
const logError = (err, ctx) => this.bareError(err, ctx, (result, set) => this.wrapWebResponse(toResponse(result, set)), this.wrapWebResponse);
|
|
2213
2220
|
if (contextless && decorations === undefined) {
|
|
2214
2221
|
// `() => ...` can't observe the context - skip allocating one entirely (errors still build
|
|
2215
2222
|
// one for the structured log, exactly like runContextlessBare).
|
|
@@ -2255,7 +2262,7 @@ export class Server {
|
|
|
2255
2262
|
* bounded parser remains shared with the generic lane; only the route-invariant entry lookups and
|
|
2256
2263
|
* lifecycle dispatch disappear from the common synchronous-validator/synchronous-handler case. */
|
|
2257
2264
|
buildFusedBodyRunner(handler, bodySchema, decorations, maxBodyBytes) {
|
|
2258
|
-
const logError = (err, ctx, wrapResponse) => this.bareError(err, ctx, wrapResponse);
|
|
2265
|
+
const logError = (err, ctx, finalize, wrapResponse) => this.bareError(err, ctx, finalize, wrapResponse);
|
|
2259
2266
|
const runHandler = (ctx, finalize, wrapResponse) => {
|
|
2260
2267
|
if (decorations !== undefined)
|
|
2261
2268
|
Object.assign(ctx, decorations);
|
|
@@ -2264,7 +2271,7 @@ export class Server {
|
|
|
2264
2271
|
output = handler(ctx);
|
|
2265
2272
|
}
|
|
2266
2273
|
catch (err) {
|
|
2267
|
-
return logError(err, ctx, wrapResponse);
|
|
2274
|
+
return logError(err, ctx, finalize, wrapResponse);
|
|
2268
2275
|
}
|
|
2269
2276
|
if (output instanceof Promise) {
|
|
2270
2277
|
return output.then((result) => {
|
|
@@ -2272,20 +2279,20 @@ export class Server {
|
|
|
2272
2279
|
return finalize(result, responseSet(ctx), ctx);
|
|
2273
2280
|
}
|
|
2274
2281
|
catch (err) {
|
|
2275
|
-
return logError(err, ctx, wrapResponse);
|
|
2282
|
+
return logError(err, ctx, finalize, wrapResponse);
|
|
2276
2283
|
}
|
|
2277
|
-
}, (err) => logError(err, ctx, wrapResponse));
|
|
2284
|
+
}, (err) => logError(err, ctx, finalize, wrapResponse));
|
|
2278
2285
|
}
|
|
2279
2286
|
try {
|
|
2280
2287
|
return finalize(output, responseSet(ctx), ctx);
|
|
2281
2288
|
}
|
|
2282
2289
|
catch (err) {
|
|
2283
|
-
return logError(err, ctx, wrapResponse);
|
|
2290
|
+
return logError(err, ctx, finalize, wrapResponse);
|
|
2284
2291
|
}
|
|
2285
2292
|
};
|
|
2286
2293
|
const runValidated = (result, ctx, finalize, wrapResponse) => {
|
|
2287
2294
|
if (result.issues !== undefined)
|
|
2288
|
-
return wrapResponse(
|
|
2295
|
+
return wrapResponse(plainValidationError(result.issues));
|
|
2289
2296
|
ctx.body = result.value;
|
|
2290
2297
|
return runHandler(ctx, finalize, wrapResponse);
|
|
2291
2298
|
};
|
|
@@ -2295,7 +2302,7 @@ export class Server {
|
|
|
2295
2302
|
validation = bodySchema["~standard"].validate(parsed);
|
|
2296
2303
|
}
|
|
2297
2304
|
catch (err) {
|
|
2298
|
-
return logError(err, ctx, wrapResponse);
|
|
2305
|
+
return logError(err, ctx, finalize, wrapResponse);
|
|
2299
2306
|
}
|
|
2300
2307
|
if (validation instanceof Promise) {
|
|
2301
2308
|
return validation.then((settled) => {
|
|
@@ -2303,15 +2310,15 @@ export class Server {
|
|
|
2303
2310
|
return runValidated(settled, ctx, finalize, wrapResponse);
|
|
2304
2311
|
}
|
|
2305
2312
|
catch (err) {
|
|
2306
|
-
return logError(err, ctx, wrapResponse);
|
|
2313
|
+
return logError(err, ctx, finalize, wrapResponse);
|
|
2307
2314
|
}
|
|
2308
|
-
}, (err) => logError(err, ctx, wrapResponse));
|
|
2315
|
+
}, (err) => logError(err, ctx, finalize, wrapResponse));
|
|
2309
2316
|
}
|
|
2310
2317
|
try {
|
|
2311
2318
|
return runValidated(validation, ctx, finalize, wrapResponse);
|
|
2312
2319
|
}
|
|
2313
2320
|
catch (err) {
|
|
2314
|
-
return logError(err, ctx, wrapResponse);
|
|
2321
|
+
return logError(err, ctx, finalize, wrapResponse);
|
|
2315
2322
|
}
|
|
2316
2323
|
};
|
|
2317
2324
|
return (source, params, search, signal, budget, platform, nativeContext, finalize, wrapResponse) => {
|
|
@@ -2319,7 +2326,7 @@ export class Server {
|
|
|
2319
2326
|
? RequestContext.native(source, params, search, maxBodyBytes, platform, this.protoPoisoning)
|
|
2320
2327
|
: new RequestContext(source, params, search, signal, budget, platform, maxBodyBytes, this.protoPoisoning);
|
|
2321
2328
|
const finish = (value) => runParsed(value, ctx, finalize, wrapResponse);
|
|
2322
|
-
return
|
|
2329
|
+
return readBodyFramed(source, maxBodyBytes, this.protoPoisoning, finish, wrapResponse, (err) => logError(err, ctx, finalize, wrapResponse));
|
|
2323
2330
|
};
|
|
2324
2331
|
}
|
|
2325
2332
|
/** Web adapter wrapper for the shared body runner. Node passes its native finalizer directly through
|
|
@@ -2335,14 +2342,10 @@ export class Server {
|
|
|
2335
2342
|
* `Response` passes through, anything else logs and returns a flat 500, and an async validator
|
|
2336
2343
|
* falls to a then-chain with the same steps. */
|
|
2337
2344
|
buildFusedQueryWeb(handler, decorations, querySchema, maxBodyBytes) {
|
|
2338
|
-
//
|
|
2339
|
-
//
|
|
2340
|
-
|
|
2341
|
-
|
|
2342
|
-
return this.wrapWebResponse(err);
|
|
2343
|
-
this.logRequestError(err, ctx);
|
|
2344
|
-
return this.wrapWebResponse(jsonError(500, "internal_error"));
|
|
2345
|
-
};
|
|
2345
|
+
// Same thrown-value contract as the generic lane (`bare-error-lane.ts` via `bareError`); the fused
|
|
2346
|
+
// lane only injects its renderer, so a thrown `status(...)` and the flat 500 fold in static headers
|
|
2347
|
+
// through `wrapWebResponse`. One source of truth for the error surface, no per-lane copy to drift.
|
|
2348
|
+
const logError = (err, ctx) => this.bareError(err, ctx, (result, set) => this.wrapWebResponse(toResponse(result, set)), this.wrapWebResponse);
|
|
2346
2349
|
const runHandler = (ctx, value) => {
|
|
2347
2350
|
ctx.query = value;
|
|
2348
2351
|
let result;
|
|
@@ -2372,19 +2375,19 @@ export class Server {
|
|
|
2372
2375
|
}
|
|
2373
2376
|
if (validation instanceof Promise) {
|
|
2374
2377
|
return validation.then((settled) => settled.issues !== undefined
|
|
2375
|
-
? this.wrapWebResponse(
|
|
2378
|
+
? this.wrapWebResponse(plainValidationError(settled.issues))
|
|
2376
2379
|
: runHandler(ctx, settled.value), (err) => logError(err, ctx));
|
|
2377
2380
|
}
|
|
2378
2381
|
if (validation.issues !== undefined)
|
|
2379
|
-
return this.wrapWebResponse(
|
|
2382
|
+
return this.wrapWebResponse(plainValidationError(validation.issues));
|
|
2380
2383
|
return runHandler(ctx, validation.value);
|
|
2381
2384
|
};
|
|
2382
2385
|
}
|
|
2383
|
-
bareError(err, ctx, wrapResponse) {
|
|
2384
|
-
|
|
2385
|
-
|
|
2386
|
-
|
|
2387
|
-
return wrapResponse(
|
|
2386
|
+
bareError(err, ctx, finalize, wrapResponse) {
|
|
2387
|
+
// The thrown-value contract lives in `bare-error-lane.ts`, one source of truth for both this
|
|
2388
|
+
// lane and the fused body runner. `responseSet` and the logger are injected because they reach
|
|
2389
|
+
// into class state; the closure is allocated only on the throw path, never on the hot lane.
|
|
2390
|
+
return renderBareError(err, ctx, finalize, wrapResponse, responseSet, (e, c) => this.logRequestError(e, c));
|
|
2388
2391
|
}
|
|
2389
2392
|
// @ts-expect-error TS6133 -- invoked structurally by compiled plans (internal/route-execution.ts)
|
|
2390
2393
|
// biome-ignore lint/correctness/noUnusedPrivateClassMembers: invoked structurally by compiled plans (internal/route-execution.ts)
|
|
@@ -2418,27 +2421,7 @@ export class Server {
|
|
|
2418
2421
|
// @ts-expect-error TS6133 -- invoked structurally by compiled plans (internal/route-execution.ts)
|
|
2419
2422
|
// biome-ignore lint/correctness/noUnusedPrivateClassMembers: invoked structurally by compiled plans (internal/route-execution.ts)
|
|
2420
2423
|
runBodyOnly(entry, source, ctx, finalize, wrapResponse) {
|
|
2421
|
-
return
|
|
2422
|
-
}
|
|
2423
|
-
/** Shared bounded body framing/parser. Both body lanes use the same trust-boundary checks; only
|
|
2424
|
-
* the validation + handler continuation differs. */
|
|
2425
|
-
readBodyInput(source, maxBodyBytes, onParsed, wrapResponse, onError) {
|
|
2426
|
-
const contentType = headerOf(source, "content-type") ?? "";
|
|
2427
|
-
if (contentType !== "application/json" && !contentType.includes("application/json")) {
|
|
2428
|
-
if (isUrlEncodedForm(contentType)) {
|
|
2429
|
-
return readBoundedForm(source, maxBodyBytes).then((form) => (form instanceof Response ? wrapResponse(form) : onParsed(form)), onError);
|
|
2430
|
-
}
|
|
2431
|
-
return Promise.resolve(wrapResponse(jsonError(415, "unsupported_media_type")));
|
|
2432
|
-
}
|
|
2433
|
-
// All JSON bodies go through readBoundedJson - the single enforcement point for the framed
|
|
2434
|
-
// fast path (post-read byte re-check), the streaming cap, and the prototype-poisoning guard.
|
|
2435
|
-
// A separate inlined native-json() fast path here would fork the trust boundary in two.
|
|
2436
|
-
try {
|
|
2437
|
-
return this.readBoundedJson(source, maxBodyBytes, (parsed) => (parsed instanceof Response ? wrapResponse(parsed) : onParsed(parsed)), onError);
|
|
2438
|
-
}
|
|
2439
|
-
catch (err) {
|
|
2440
|
-
return Promise.resolve(onError(err));
|
|
2441
|
-
}
|
|
2424
|
+
return readBodyFramed(source, entry.bodyLimit ?? UNLIMITED_BODY_BYTES, this.protoPoisoning, (parsed) => this.finishBodyOnly(entry, parsed, ctx, finalize, wrapResponse), wrapResponse, (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse));
|
|
2442
2425
|
}
|
|
2443
2426
|
/** Validate + run the handler for the bodyOnly path - shared by the inline fast path and the
|
|
2444
2427
|
* streaming fallback. A method (not per-request closures) so the hot path allocates nothing
|
|
@@ -2493,13 +2476,13 @@ export class Server {
|
|
|
2493
2476
|
if (validation instanceof Promise) {
|
|
2494
2477
|
return validation.then((settled) => {
|
|
2495
2478
|
if (settled.issues !== undefined)
|
|
2496
|
-
return wrapResponse(
|
|
2479
|
+
return wrapResponse(plainValidationError(settled.issues));
|
|
2497
2480
|
ctx.body = settled.value;
|
|
2498
2481
|
return this.executeHandler(entry, ctx, finalize);
|
|
2499
2482
|
});
|
|
2500
2483
|
}
|
|
2501
2484
|
if (validation.issues !== undefined)
|
|
2502
|
-
return wrapResponse(
|
|
2485
|
+
return wrapResponse(plainValidationError(validation.issues));
|
|
2503
2486
|
ctx.body = validation.value;
|
|
2504
2487
|
return this.executeHandler(entry, ctx, finalize);
|
|
2505
2488
|
}
|
|
@@ -2508,18 +2491,18 @@ export class Server {
|
|
|
2508
2491
|
if (validation instanceof Promise) {
|
|
2509
2492
|
return validation.then((settled) => {
|
|
2510
2493
|
if (settled.issues !== undefined)
|
|
2511
|
-
return wrapResponse(
|
|
2494
|
+
return wrapResponse(plainValidationError(settled.issues));
|
|
2512
2495
|
ctx.query = settled.value;
|
|
2513
2496
|
return this.executeHandler(entry, ctx, finalize, wrapResponse);
|
|
2514
2497
|
}, (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse));
|
|
2515
2498
|
}
|
|
2516
2499
|
if (validation.issues !== undefined)
|
|
2517
|
-
return wrapResponse(
|
|
2500
|
+
return wrapResponse(plainValidationError(validation.issues));
|
|
2518
2501
|
ctx.query = validation.value;
|
|
2519
2502
|
return this.executeHandler(entry, ctx, finalize, wrapResponse);
|
|
2520
2503
|
}
|
|
2521
2504
|
}
|
|
2522
|
-
return wrapResponse(
|
|
2505
|
+
return wrapResponse(plainValidationError(originalIssues));
|
|
2523
2506
|
}
|
|
2524
2507
|
applyBodyValidation(entry, result, ctx, finalize, wrapResponse) {
|
|
2525
2508
|
if (result.issues !== undefined) {
|
|
@@ -2531,7 +2514,7 @@ export class Server {
|
|
|
2531
2514
|
}
|
|
2532
2515
|
return this.handleValidationErrorRecovery(entry, recovery, ctx, finalize, wrapResponse, result.issues, "body");
|
|
2533
2516
|
}
|
|
2534
|
-
return wrapResponse(
|
|
2517
|
+
return wrapResponse(plainValidationError(result.issues));
|
|
2535
2518
|
}
|
|
2536
2519
|
ctx.body = result.value;
|
|
2537
2520
|
return this.executeHandler(entry, ctx, finalize);
|
|
@@ -2574,7 +2557,7 @@ export class Server {
|
|
|
2574
2557
|
}
|
|
2575
2558
|
return this.handleValidationErrorRecovery(entry, recovery, ctx, finalize, wrapResponse, result.issues, "query");
|
|
2576
2559
|
}
|
|
2577
|
-
return wrapResponse(
|
|
2560
|
+
return wrapResponse(plainValidationError(result.issues));
|
|
2578
2561
|
}
|
|
2579
2562
|
ctx.query = result.value;
|
|
2580
2563
|
return this.executeHandler(entry, ctx, finalize, wrapResponse);
|
|
@@ -2869,6 +2852,8 @@ export class Server {
|
|
|
2869
2852
|
if (extension instanceof Promise) {
|
|
2870
2853
|
return this.continueLifecycleAfterDerive(entry, ctx, finalize, wrapResponse, i, extension);
|
|
2871
2854
|
}
|
|
2855
|
+
if (isDeriveEarlyExit(extension))
|
|
2856
|
+
return finalize(extension, responseSet(ctx));
|
|
2872
2857
|
Object.assign(ctx, extension);
|
|
2873
2858
|
}
|
|
2874
2859
|
for (let i = 0; i < entry.beforeHandle.length; i++) {
|
|
@@ -2896,6 +2881,8 @@ export class Server {
|
|
|
2896
2881
|
if (extension instanceof Promise) {
|
|
2897
2882
|
return this.continueLifecycleAfterDerive(entry, ctx, finalize, wrapResponse, 0, extension);
|
|
2898
2883
|
}
|
|
2884
|
+
if (isDeriveEarlyExit(extension))
|
|
2885
|
+
return finalize(extension, responseSet(ctx));
|
|
2899
2886
|
Object.assign(ctx, extension);
|
|
2900
2887
|
const outcome = entry.beforeHandle[0](ctx);
|
|
2901
2888
|
if (outcome instanceof Promise) {
|
|
@@ -2924,10 +2911,16 @@ export class Server {
|
|
|
2924
2911
|
}
|
|
2925
2912
|
async continueLifecycleAfterDerive(entry, ctx, finalize, wrapResponse, firstIndex, first) {
|
|
2926
2913
|
try {
|
|
2927
|
-
|
|
2914
|
+
const settled = await first;
|
|
2915
|
+
if (isDeriveEarlyExit(settled))
|
|
2916
|
+
return finalize(settled, responseSet(ctx));
|
|
2917
|
+
Object.assign(ctx, settled);
|
|
2928
2918
|
for (let i = firstIndex + 1; i < entry.derives.length; i++) {
|
|
2929
|
-
const
|
|
2930
|
-
|
|
2919
|
+
const raw = entry.derives[i](ctx);
|
|
2920
|
+
const extension = raw instanceof Promise ? await raw : raw;
|
|
2921
|
+
if (isDeriveEarlyExit(extension))
|
|
2922
|
+
return finalize(extension, responseSet(ctx));
|
|
2923
|
+
Object.assign(ctx, extension);
|
|
2931
2924
|
}
|
|
2932
2925
|
return await this.runLifecycleHooksAsync(entry, ctx, finalize, wrapResponse);
|
|
2933
2926
|
}
|
|
@@ -3031,7 +3024,7 @@ export class Server {
|
|
|
3031
3024
|
path: pathnameOf(ctx.req.url),
|
|
3032
3025
|
detail: outcome.message,
|
|
3033
3026
|
});
|
|
3034
|
-
return wrapResponse(
|
|
3027
|
+
return wrapResponse(plainError(500, "internal_error"));
|
|
3035
3028
|
}
|
|
3036
3029
|
if (outcome.kind === "warn") {
|
|
3037
3030
|
this.logger.warn("response contract", {
|
|
@@ -3042,14 +3035,33 @@ export class Server {
|
|
|
3042
3035
|
}
|
|
3043
3036
|
return finalize(outcome.value, responseSet(ctx));
|
|
3044
3037
|
}
|
|
3045
|
-
|
|
3038
|
+
/** Synchronous by design, and `MaybePromise` rather than `Promise` for the same reason: control flow
|
|
3039
|
+
* out of a lifecycle stage must not cost a promise. An `async` method allocates one and defers the
|
|
3040
|
+
* result a microtask even when every branch it takes returns immediately, which is the common case
|
|
3041
|
+
* here - a thrown `status(...)`/`Response`, or a route with no `onError` hook at all. Only the hook
|
|
3042
|
+
* loop, which may await, drops into {@link runErrorHooks}. */
|
|
3043
|
+
handleLifecycleError(entry, err, ctx, finalize, wrapResponse) {
|
|
3046
3044
|
// A *thrown* Response is deliberate control flow, not an error - a guard throws a redirect/401,
|
|
3047
3045
|
// an action throws an error page. Return it as-is (Remix/SvelteKit semantics); don't run onError
|
|
3048
3046
|
// or log it as a 500. This is what makes `throw redirect(...)` / `requireSession(...)` work from
|
|
3049
3047
|
// any handler or loader.
|
|
3050
3048
|
if (err instanceof Response)
|
|
3051
3049
|
return wrapResponse(err);
|
|
3052
|
-
//
|
|
3050
|
+
// Same rule for a thrown `status(...)`, but through `finalize` rather than `wrapResponse`: the
|
|
3051
|
+
// value is still plain data, so the ordinary JSON lane renders it and no `Response` is built.
|
|
3052
|
+
if (isResponseResult(err))
|
|
3053
|
+
return finalize(err, responseSet(ctx));
|
|
3054
|
+
if (entry.onError.length === 0) {
|
|
3055
|
+
// Never crash the server or leak internals. The client gets a flat 500; the detail goes to the
|
|
3056
|
+
// (redacting) logger. Body-read failures and around-hook failures land here too.
|
|
3057
|
+
this.logRequestError(err, ctx);
|
|
3058
|
+
return wrapResponse(plainError(500, "internal_error"));
|
|
3059
|
+
}
|
|
3060
|
+
return this.runErrorHooks(entry, err, ctx, finalize, wrapResponse);
|
|
3061
|
+
}
|
|
3062
|
+
/** The `onError` chain, split off {@link handleLifecycleError} so its `await` costs only the routes
|
|
3063
|
+
* that registered a hook. A hook may return a custom response; otherwise the default 500 stands. */
|
|
3064
|
+
async runErrorHooks(entry, err, ctx, finalize, wrapResponse) {
|
|
3053
3065
|
// Indexed for the same reason as the `beforeHandle` chain above: the iterator would outlive
|
|
3054
3066
|
// the `await` and stay heap-allocated on both engines.
|
|
3055
3067
|
for (let i = 0; i < entry.onError.length; i++) {
|
|
@@ -3058,49 +3070,33 @@ export class Server {
|
|
|
3058
3070
|
if (handled !== undefined)
|
|
3059
3071
|
return finalize(handled, responseSet(ctx));
|
|
3060
3072
|
}
|
|
3061
|
-
// Never crash the server or leak internals. The client gets a flat 500; the detail goes to the
|
|
3062
|
-
// (redacting) logger. Body-read failures and around-hook failures land here too.
|
|
3063
3073
|
this.logRequestError(err, ctx);
|
|
3064
|
-
return wrapResponse(
|
|
3074
|
+
return wrapResponse(plainError(500, "internal_error"));
|
|
3065
3075
|
}
|
|
3066
3076
|
/** Log an unhandled request error to the (redacting) logger - shared by {@link runLifecycle} and the
|
|
3067
3077
|
* bare fast path ({@link bareError}) so both record the same fields. Never throws; never leaks. */
|
|
3068
3078
|
logRequestError(err, ctx) {
|
|
3069
|
-
|
|
3070
|
-
// the app's call (`errorLogDetail`). The default keeps it: a 500 with no message and no stack is
|
|
3071
|
-
// an incident nobody can diagnose, and the framework already ships the narrower instrument for
|
|
3072
|
-
// the leak - a redacting logger with `valuePatterns`. `"none"` is there for an untrusted sink.
|
|
3073
|
-
const detail = this.errorLogDetail;
|
|
3074
|
-
this.logger.error("unhandled request error", {
|
|
3075
|
-
method: ctx.req.method,
|
|
3076
|
-
path: pathnameOf(ctx.req.url),
|
|
3077
|
-
name: err instanceof Error ? err.name : "Error",
|
|
3078
|
-
// `detail`, not `message`: the logger uses `message` for its own first argument, so a field of
|
|
3079
|
-
// that name is silently overwritten and the thrown error's own text never reaches the sink. It
|
|
3080
|
-
// survived only incidentally inside `stack`, and was lost outright for a non-Error throw.
|
|
3081
|
-
...(detail === "none" ? {} : { detail: err instanceof Error ? err.message : String(err) }),
|
|
3082
|
-
...(detail === "full" && err instanceof Error ? { stack: err.stack } : {}),
|
|
3083
|
-
});
|
|
3079
|
+
emitRequestErrorLog(this.logger, this.errorLogDetail, err, ctx);
|
|
3084
3080
|
}
|
|
3085
3081
|
async readAndValidateBody(req, entry, ctx) {
|
|
3086
3082
|
const contentType = headerOf(req, "content-type") ?? "";
|
|
3087
3083
|
let parsed;
|
|
3088
3084
|
if (contentType === "application/json" || contentType.includes("application/json")) {
|
|
3089
|
-
const json = await this.readBoundedJson(req, entry.bodyLimit ??
|
|
3090
|
-
if (json
|
|
3085
|
+
const json = await this.readBoundedJson(req, entry.bodyLimit ?? UNLIMITED_BODY_BYTES);
|
|
3086
|
+
if (isResponseResult(json))
|
|
3091
3087
|
return json;
|
|
3092
3088
|
parsed = json;
|
|
3093
3089
|
}
|
|
3094
3090
|
else if (isUrlEncodedForm(contentType)) {
|
|
3095
|
-
const form = await readBoundedForm(req, entry.bodyLimit ??
|
|
3096
|
-
if (form
|
|
3091
|
+
const form = await readBoundedForm(req, entry.bodyLimit ?? UNLIMITED_BODY_BYTES);
|
|
3092
|
+
if (isResponseResult(form))
|
|
3097
3093
|
return form;
|
|
3098
3094
|
parsed = form;
|
|
3099
3095
|
}
|
|
3100
3096
|
else {
|
|
3101
3097
|
// multipart/form-data (file uploads) stays 415 on the schema path by design - files don't
|
|
3102
3098
|
// fit a value schema; use a schema-less route + @nifrajs/uploads helpers for those.
|
|
3103
|
-
return
|
|
3099
|
+
return plainError(415, "unsupported_media_type");
|
|
3104
3100
|
}
|
|
3105
3101
|
const validation = entry.schema.body["~standard"].validate(parsed);
|
|
3106
3102
|
const result = validation instanceof Promise ? await validation : validation;
|
|
@@ -3125,7 +3121,7 @@ export class Server {
|
|
|
3125
3121
|
}
|
|
3126
3122
|
const hook = entry.schema?.onValidationError ?? this.defaultOnValidationError;
|
|
3127
3123
|
if (hook === undefined)
|
|
3128
|
-
return
|
|
3124
|
+
return plainValidationError(result.issues);
|
|
3129
3125
|
const attempted = hook(result.issues, ctx, kind);
|
|
3130
3126
|
if (attempted instanceof Promise) {
|
|
3131
3127
|
return attempted.then((recovery) => this.finishLifecycleValidationRecovery(entry, kind, result.issues, recovery, assign));
|
|
@@ -3134,7 +3130,7 @@ export class Server {
|
|
|
3134
3130
|
}
|
|
3135
3131
|
finishLifecycleValidationRecovery(entry, kind, issues, recovery, assign) {
|
|
3136
3132
|
if (recovery === undefined)
|
|
3137
|
-
return
|
|
3133
|
+
return plainValidationError(issues);
|
|
3138
3134
|
if (recovery instanceof Response)
|
|
3139
3135
|
return recovery;
|
|
3140
3136
|
const schema = kind === "body"
|
|
@@ -3148,13 +3144,13 @@ export class Server {
|
|
|
3148
3144
|
if (retried instanceof Promise) {
|
|
3149
3145
|
return retried.then((settled) => {
|
|
3150
3146
|
if (settled.issues !== undefined)
|
|
3151
|
-
return
|
|
3147
|
+
return plainValidationError(settled.issues);
|
|
3152
3148
|
assign(settled.value);
|
|
3153
3149
|
return undefined;
|
|
3154
3150
|
});
|
|
3155
3151
|
}
|
|
3156
3152
|
if (retried.issues !== undefined)
|
|
3157
|
-
return
|
|
3153
|
+
return plainValidationError(retried.issues);
|
|
3158
3154
|
assign(retried.value);
|
|
3159
3155
|
return undefined;
|
|
3160
3156
|
}
|