@nifrajs/core 2.13.0 → 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/binary.d.ts +32 -0
- package/dist/binary.d.ts.map +1 -1
- package/dist/binary.js +17 -0
- package/dist/binary.js.map +1 -1
- package/dist/durable-adapters.d.ts.map +1 -1
- package/dist/durable-adapters.js +21 -50
- package/dist/durable-adapters.js.map +1 -1
- package/dist/durable-execution.d.ts.map +1 -1
- package/dist/durable-execution.js +49 -69
- package/dist/durable-execution.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/durable-approval-protocol.d.ts +13 -0
- package/dist/internal/durable-approval-protocol.d.ts.map +1 -0
- package/dist/internal/durable-approval-protocol.js +66 -0
- package/dist/internal/durable-approval-protocol.js.map +1 -0
- package/dist/internal/durable-effect-protocol.d.ts +14 -0
- package/dist/internal/durable-effect-protocol.d.ts.map +1 -0
- package/dist/internal/durable-effect-protocol.js +16 -0
- package/dist/internal/durable-effect-protocol.js.map +1 -0
- package/dist/internal/durable-saga-protocol.d.ts +8 -0
- package/dist/internal/durable-saga-protocol.d.ts.map +1 -0
- package/dist/internal/durable-saga-protocol.js +21 -0
- package/dist/internal/durable-saga-protocol.js.map +1 -0
- package/dist/internal/route-catalog.d.ts +3 -0
- package/dist/internal/route-catalog.d.ts.map +1 -1
- package/dist/internal/route-catalog.js +5 -0
- package/dist/internal/route-catalog.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/plugin.d.ts +1 -1
- package/dist/server/plugin.d.ts.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/registry.d.ts +11 -2
- package/dist/server/registry.d.ts.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 +82 -45
- 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 +18 -1
- package/dist/server/server-types.d.ts.map +1 -1
- package/dist/server/server.d.ts +89 -48
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +397 -276
- 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/binary.ts +35 -0
- package/src/durable-adapters.ts +26 -51
- package/src/durable-execution.ts +46 -88
- package/src/edge-kit.ts +27 -0
- package/src/errors.ts +1 -0
- package/src/index.ts +1 -1
- package/src/internal/durable-approval-protocol.ts +92 -0
- package/src/internal/durable-effect-protocol.ts +37 -0
- package/src/internal/durable-saga-protocol.ts +55 -0
- package/src/internal/route-catalog.ts +6 -0
- 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/plugin.ts +1 -1
- package/src/server/query.ts +10 -4
- package/src/server/registry.ts +19 -4
- package/src/server/request-context.ts +106 -51
- 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 +18 -1
- package/src/server/server.ts +607 -376
- 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,9 +152,92 @@ 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");
|
|
165
|
+
// The unused-order-scoped-hook audit (recorder, seal check, stack capture, message) is a dev-time
|
|
166
|
+
// diagnostic. Every guard below keeps the INLINE literal `process.env.NODE_ENV !== "production"` at the
|
|
167
|
+
// branch - never a shared const holding the folded boolean - because a bundler's `process.env.NODE_ENV`
|
|
168
|
+
// define only dead-code-eliminates the audit when the literal appears at the `if`; Bun does not
|
|
169
|
+
// propagate a module const's value into the branch. Every real production bundler (vite/esbuild/webpack/
|
|
170
|
+
// next, `bun build --production`) injects that define, turning each guard into `hookAuditRuntime &&
|
|
171
|
+
// false`, which folds away - dropping the whole audit and restoring the bare kernel size.
|
|
172
|
+
//
|
|
173
|
+
// `hookAuditRuntime` is the one shared flag: `typeof globalThis.Deno === "undefined"`. Deno ships a
|
|
174
|
+
// node-compat `process`, so `typeof process` does NOT gate it out there, yet reading `process.env`
|
|
175
|
+
// throws `NotCapable` without `--allow-env`. The flag short-circuits before the env read on Deno (audit
|
|
176
|
+
// off) while a production define still folds `<flag> && false` to `false` on every other runtime.
|
|
177
|
+
const hookAuditRuntime = typeof globalThis.Deno === "undefined";
|
|
178
|
+
// This module's own path, captured once from a sentinel stack. Every internal frame of an
|
|
179
|
+
// order-scoped hook push (the public method, its `use()`-bundle dispatcher, the recorder) lives here,
|
|
180
|
+
// so `captureCallerSite` skips anything starting with it and lands on the user's calling frame.
|
|
181
|
+
const SERVER_MODULE_PATH = /* @__PURE__ */ (() => {
|
|
182
|
+
const frame = new Error().stack?.split("\n")[1];
|
|
183
|
+
return frame?.match(/\(?([^()\s]+):\d+:\d+\)?\s*$/)?.[1];
|
|
184
|
+
})();
|
|
185
|
+
/** One `file:line:col` frame for the caller of an order-scoped hook method, for the unused-hook
|
|
186
|
+
* report. Only ever called behind the `unusedScopedHooks !== "off"` guard, so the `Error` (the sole
|
|
187
|
+
* non-trivial cost) is never constructed when the check is disabled. */
|
|
188
|
+
const captureCallerSite = () => {
|
|
189
|
+
const stack = new Error().stack;
|
|
190
|
+
if (stack === undefined)
|
|
191
|
+
return undefined;
|
|
192
|
+
for (const line of stack.split("\n").slice(1)) {
|
|
193
|
+
const frame = line.match(/\(?([^()\s]+:\d+:\d+)\)?\s*$/)?.[1];
|
|
194
|
+
if (frame === undefined)
|
|
195
|
+
continue;
|
|
196
|
+
if (SERVER_MODULE_PATH !== undefined && frame.startsWith(`${SERVER_MODULE_PATH}:`))
|
|
197
|
+
continue;
|
|
198
|
+
return frame;
|
|
199
|
+
}
|
|
200
|
+
return undefined;
|
|
201
|
+
};
|
|
202
|
+
const hookAudit = /* @__PURE__ */ new WeakMap();
|
|
203
|
+
/** Start tracking one server's order-scoped hook pushes (once, from the constructor). */
|
|
204
|
+
const beginHookAudit = (key, policy, hasCustomLogger) => {
|
|
205
|
+
hookAudit.set(key, { policy, hasCustomLogger, sites: [], checked: false });
|
|
206
|
+
};
|
|
207
|
+
/** Record one order-scoped hook push: the route count at that moment (so `routesAtPush === total` at
|
|
208
|
+
* seal means no route was declared after it) and the caller's frame. */
|
|
209
|
+
const recordScopedHook = (key, routesAtPush, kind) => {
|
|
210
|
+
const state = hookAudit.get(key);
|
|
211
|
+
if (state === undefined || state.policy === "off")
|
|
212
|
+
return;
|
|
213
|
+
state.sites.push({ kind, routesAtPush, site: captureCallerSite() });
|
|
214
|
+
};
|
|
215
|
+
/** Run the deadness check exactly once. A hook whose recorded route count equals the final route count
|
|
216
|
+
* had no route declared after it - unambiguously dead, since the scoping feature (a hook that covers
|
|
217
|
+
* only later routes) always leaves at least one such route. Zero-route apps are exempt (different
|
|
218
|
+
* mistake, and the warning would be noise). */
|
|
219
|
+
const sealHookAudit = (key, total, logger) => {
|
|
220
|
+
const state = hookAudit.get(key);
|
|
221
|
+
if (state === undefined || state.checked)
|
|
222
|
+
return;
|
|
223
|
+
state.checked = true;
|
|
224
|
+
if (state.policy === "off" || total === 0)
|
|
225
|
+
return;
|
|
226
|
+
const dead = state.sites.filter((hook) => hook.routesAtPush === total);
|
|
227
|
+
if (dead.length === 0)
|
|
228
|
+
return;
|
|
229
|
+
const detail = dead.map((hook) => ` ${hook.kind}() at ${hook.site ?? "<unknown>"}`).join("\n");
|
|
230
|
+
const message = `[nifra] ${dead.length} order-scoped hook(s) apply to no route - they were added after the ` +
|
|
231
|
+
`last route was registered, and register() snapshots the chain into each route as it is ` +
|
|
232
|
+
`declared:\n${detail}\nMove them before the routes they should cover. App-global hooks ` +
|
|
233
|
+
`(onRequest/onResponse) are not order-scoped and can be added at any time.`;
|
|
234
|
+
if (state.policy === "error")
|
|
235
|
+
throw new FrameworkError("UNUSED_SCOPED_HOOKS", message);
|
|
236
|
+
if (state.hasCustomLogger)
|
|
237
|
+
logger.warn(message);
|
|
238
|
+
else
|
|
239
|
+
console.warn(message);
|
|
240
|
+
};
|
|
226
241
|
/**
|
|
227
242
|
* The inline server. Routes are chainable and fully type-inferred. `derive`/
|
|
228
243
|
* `decorate` extend the handler context (`Ctx`) for routes defined *after* them,
|
|
@@ -380,6 +395,11 @@ export class Server {
|
|
|
380
395
|
this.defaultOnValidationError = options.onValidationError;
|
|
381
396
|
this.bunServer = undefined;
|
|
382
397
|
this.sealed = false;
|
|
398
|
+
// Guarded so a production define strips the call, leaving `beginHookAudit`/`hookAudit` unreferenced
|
|
399
|
+
// for the bundler to eliminate; a non-bundled production run skips it the same way.
|
|
400
|
+
if (hookAuditRuntime && process.env.NODE_ENV !== "production") {
|
|
401
|
+
beginHookAudit(this, options.unusedScopedHooks ?? "warn", options.logger !== undefined);
|
|
402
|
+
}
|
|
383
403
|
this.derives = [];
|
|
384
404
|
this.decorations = {};
|
|
385
405
|
this.beforeHandleHooks = [];
|
|
@@ -439,16 +459,34 @@ export class Server {
|
|
|
439
459
|
this.fetchMounts.sort((a, b) => b.path.length - a.path.length);
|
|
440
460
|
return this;
|
|
441
461
|
}
|
|
442
|
-
/**
|
|
462
|
+
/**
|
|
463
|
+
* Add a per-request, computed context extension. Order-scoped: it is snapshotted into every route
|
|
464
|
+
* declared *after* this call, and reaches none declared before. One added after the last route
|
|
465
|
+
* (e.g. on the app returned by a route-registering factory) covers nothing - see
|
|
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.
|
|
472
|
+
*/
|
|
443
473
|
derive(fn) {
|
|
444
474
|
this.assertConfigurable("derive()");
|
|
445
475
|
this.derives.push(fn);
|
|
476
|
+
if (hookAuditRuntime && process.env.NODE_ENV !== "production")
|
|
477
|
+
recordScopedHook(this, this.catalog.size, "derive");
|
|
446
478
|
return this;
|
|
447
479
|
}
|
|
448
|
-
/**
|
|
480
|
+
/**
|
|
481
|
+
* Add a static context value. Order-scoped: captured by every route declared *after* this call, and
|
|
482
|
+
* by none before; one added after the last route reaches nothing
|
|
483
|
+
* (see {@link ServerOptions.unusedScopedHooks}).
|
|
484
|
+
*/
|
|
449
485
|
decorate(key, value) {
|
|
450
486
|
this.assertConfigurable("decorate()");
|
|
451
487
|
this.decorations[key] = value;
|
|
488
|
+
if (hookAuditRuntime && process.env.NODE_ENV !== "production")
|
|
489
|
+
recordScopedHook(this, this.catalog.size, "decorate");
|
|
452
490
|
return this;
|
|
453
491
|
}
|
|
454
492
|
/**
|
|
@@ -462,20 +500,31 @@ export class Server {
|
|
|
462
500
|
this.nodeRequestHooksComplete = false;
|
|
463
501
|
return this;
|
|
464
502
|
}
|
|
465
|
-
/**
|
|
503
|
+
/**
|
|
504
|
+
* Run after validation, before the handler; a non-`undefined` return short-circuits. Order-scoped:
|
|
505
|
+
* it is snapshotted into every route declared *after* this call and covers none declared before, so
|
|
506
|
+
* one added after the last route reaches nothing (see {@link ServerOptions.unusedScopedHooks}).
|
|
507
|
+
*/
|
|
466
508
|
beforeHandle(fn) {
|
|
467
509
|
this.assertConfigurable("beforeHandle()");
|
|
468
510
|
this.beforeHandleHooks.push(fn);
|
|
511
|
+
if (hookAuditRuntime && process.env.NODE_ENV !== "production")
|
|
512
|
+
recordScopedHook(this, this.catalog.size, "beforeHandle");
|
|
469
513
|
return this;
|
|
470
514
|
}
|
|
471
515
|
/**
|
|
472
516
|
* Wrap the matched route lifecycle for subsequent routes. This is intentionally generic over the
|
|
473
517
|
* route output, so wrappers like async context storage do not force Node's direct JSON path through
|
|
474
518
|
* a Web `Response`. The first registered wrapper is outermost.
|
|
519
|
+
*
|
|
520
|
+
* Order-scoped: captured by every route declared *after* this call, none before; one added after the
|
|
521
|
+
* last route reaches nothing (see {@link ServerOptions.unusedScopedHooks}).
|
|
475
522
|
*/
|
|
476
523
|
around(fn) {
|
|
477
524
|
this.assertConfigurable("around()");
|
|
478
525
|
this.aroundHooks.push(fn);
|
|
526
|
+
if (hookAuditRuntime && process.env.NODE_ENV !== "production")
|
|
527
|
+
recordScopedHook(this, this.catalog.size, "around");
|
|
479
528
|
return this;
|
|
480
529
|
}
|
|
481
530
|
/**
|
|
@@ -494,6 +543,8 @@ export class Server {
|
|
|
494
543
|
}
|
|
495
544
|
// JS timers wrap larger delays to ~1ms, which would unexpectedly deny every effect.
|
|
496
545
|
this.capabilityInterceptors.push(Object.freeze({ interceptor, timeoutMs: Math.min(timeoutMs, 2_147_483_647) }));
|
|
546
|
+
if (hookAuditRuntime && process.env.NODE_ENV !== "production")
|
|
547
|
+
recordScopedHook(this, this.catalog.size, "aroundCapability");
|
|
497
548
|
return this;
|
|
498
549
|
}
|
|
499
550
|
/** Observe token-only admission/execution lifecycle events for subsequent capability routes. */
|
|
@@ -505,16 +556,24 @@ export class Server {
|
|
|
505
556
|
this.capabilityObservers.push(observer);
|
|
506
557
|
return this;
|
|
507
558
|
}
|
|
508
|
-
/** Transform the handler's result before it is serialized. Order-scoped
|
|
559
|
+
/** Transform the handler's result before it is serialized. Order-scoped: captured by routes declared
|
|
560
|
+
* *after* this call, none before; one added after the last route reaches nothing (see
|
|
561
|
+
* {@link ServerOptions.unusedScopedHooks}). */
|
|
509
562
|
afterHandle(fn) {
|
|
510
563
|
this.assertConfigurable("afterHandle()");
|
|
511
564
|
this.afterHandleHooks.push(fn);
|
|
565
|
+
if (hookAuditRuntime && process.env.NODE_ENV !== "production")
|
|
566
|
+
recordScopedHook(this, this.catalog.size, "afterHandle");
|
|
512
567
|
return this;
|
|
513
568
|
}
|
|
514
|
-
/** Handle a thrown error; a non-`undefined` return becomes the response (else the default 500).
|
|
569
|
+
/** Handle a thrown error; a non-`undefined` return becomes the response (else the default 500).
|
|
570
|
+
* Order-scoped: captured by routes declared *after* this call, none before; one added after the last
|
|
571
|
+
* route reaches nothing (see {@link ServerOptions.unusedScopedHooks}). */
|
|
515
572
|
onError(fn) {
|
|
516
573
|
this.assertConfigurable("onError()");
|
|
517
574
|
this.onErrorHooks.push(fn);
|
|
575
|
+
if (hookAuditRuntime && process.env.NODE_ENV !== "production")
|
|
576
|
+
recordScopedHook(this, this.catalog.size, "onError");
|
|
518
577
|
return this;
|
|
519
578
|
}
|
|
520
579
|
/** Transform every outgoing response - success, error, 404, 405, short-circuit. Global. */
|
|
@@ -556,19 +615,33 @@ export class Server {
|
|
|
556
615
|
* hook owns part of the header state - into a hook that runs in the right order. */
|
|
557
616
|
addStaticResponseHeaders(record) {
|
|
558
617
|
if (this.onResponseHooks.length > 0) {
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
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;
|
|
565
638
|
}
|
|
566
639
|
const merged = this.staticResponseHeaders === undefined
|
|
567
640
|
? record
|
|
568
641
|
: { ...this.staticResponseHeaders.record, ...record };
|
|
569
642
|
this.staticResponseHeaders = buildStaticResponseHeaders(merged);
|
|
570
643
|
const statics = this.staticResponseHeaders;
|
|
571
|
-
this.wrapWebResponse = (response) => applyStaticResponseHeaders(response, statics);
|
|
644
|
+
this.wrapWebResponse = (response) => applyStaticResponseHeaders(webResponseOf(response), statics);
|
|
572
645
|
this.webResponseTimeout = () => applyStaticResponseHeaders(RESPONSE_TIMEOUT(), statics);
|
|
573
646
|
return this;
|
|
574
647
|
}
|
|
@@ -579,58 +652,14 @@ export class Server {
|
|
|
579
652
|
}
|
|
580
653
|
return this.responseBodyTag;
|
|
581
654
|
}
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
this.onResponseHooks.push(webResponseRawHook(fn, this.responseBodyOwners));
|
|
591
|
-
this.onNodeResponseHooks.push(() => undefined);
|
|
592
|
-
return this;
|
|
593
|
-
}
|
|
594
|
-
/**
|
|
595
|
-
* Register a PORTABLE header-only response hook - one implementation, fast on every runtime.
|
|
596
|
-
*
|
|
597
|
-
* The hook receives a mutable case-insensitive header view, a minimal request view, and the
|
|
598
|
-
* status. On the Web serving paths it runs inside the normal `onResponse` walk against the
|
|
599
|
-
* response's own `Headers` (mutating in place - no clone). On Node it self-pairs as a native
|
|
600
|
-
* response hook against the outcome's plain header record, so registering one NEVER forces the
|
|
601
|
-
* Node adapter off its direct socket writer the way a full `onResponse(res: Response)` hook does.
|
|
602
|
-
* Prefer this over `onResponse` whenever the middleware only reads/writes headers.
|
|
603
|
-
*/
|
|
604
|
-
onResponseHeaders(fn) {
|
|
605
|
-
this.assertConfigurable("onResponseHeaders()");
|
|
606
|
-
this.onResponseHooks.push(webResponseHeadersHook(fn));
|
|
607
|
-
this.onNodeResponseHooks.push((response, req) => fn(recordHeadersView(response), req, response.status));
|
|
608
|
-
return this;
|
|
609
|
-
}
|
|
610
|
-
/**
|
|
611
|
-
* Register a PORTABLE post-serialization body hook - the payload tier. The hook receives the
|
|
612
|
-
* FINAL framework-serialized bytes (plus the mutable header view and status) and may return
|
|
613
|
-
* replacement bytes; `undefined` keeps the body. On the Node direct writer the bytes come
|
|
614
|
-
* straight off the outcome record; on the Web serving paths they ride the framework-built
|
|
615
|
-
* Response as a tag, so no body stream is ever drained on any runtime. A handler-returned raw
|
|
616
|
-
* `Response` (proxied fetch, SSE, streamed SSR) is skipped by contract - transforming those is
|
|
617
|
-
* what the full `onResponse` hook is for.
|
|
618
|
-
*/
|
|
619
|
-
onResponseBody(fn) {
|
|
620
|
-
this.assertConfigurable("onResponseBody()");
|
|
621
|
-
this.enableResponseBodyTagging();
|
|
622
|
-
this.onResponseHooks.push(webResponseBodyHook(fn, this.responseBodyOwners));
|
|
623
|
-
this.onNodeResponseHooks.push((response, req) => {
|
|
624
|
-
const body = response.body;
|
|
625
|
-
if (body === null)
|
|
626
|
-
return undefined;
|
|
627
|
-
const out = fn(body, recordHeadersView(response), req, response.status);
|
|
628
|
-
if (out instanceof Promise)
|
|
629
|
-
return out.then((replaced) => applyBodyReplacement(response, replaced));
|
|
630
|
-
applyBodyReplacement(response, out);
|
|
631
|
-
return undefined;
|
|
632
|
-
});
|
|
633
|
-
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;
|
|
634
663
|
}
|
|
635
664
|
/** Observe the terminal response after all transformations. Observers are ordered and fail-open. */
|
|
636
665
|
onResponseFinalized(fn) {
|
|
@@ -676,6 +705,15 @@ export class Server {
|
|
|
676
705
|
}
|
|
677
706
|
this.globalAssurance.push(...evidence.filter((item) => item.scope === "global"));
|
|
678
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
|
+
}
|
|
679
717
|
if (arg.onRequest !== undefined) {
|
|
680
718
|
this.assertConfigurable("onRequest()");
|
|
681
719
|
this.onRequestHooks.push(arg.onRequest);
|
|
@@ -709,11 +747,11 @@ export class Server {
|
|
|
709
747
|
if (arg.responseHeaders !== undefined)
|
|
710
748
|
this.responseHeaders(arg.responseHeaders);
|
|
711
749
|
if (arg.onResponseHeaders !== undefined)
|
|
712
|
-
this.onResponseHeaders(arg.onResponseHeaders);
|
|
750
|
+
this.responseObserverMethods().onResponseHeaders(arg.onResponseHeaders);
|
|
713
751
|
if (arg.onResponseBody !== undefined)
|
|
714
|
-
this.onResponseBody(arg.onResponseBody);
|
|
752
|
+
this.responseObserverMethods().onResponseBody(arg.onResponseBody);
|
|
715
753
|
if (arg.onResponseRaw !== undefined)
|
|
716
|
-
this.onResponseRaw(arg.onResponseRaw);
|
|
754
|
+
this.responseObserverMethods().onResponseRaw(arg.onResponseRaw);
|
|
717
755
|
if (arg.onResponseFinalized !== undefined)
|
|
718
756
|
this.onResponseFinalized(arg.onResponseFinalized);
|
|
719
757
|
if (arg.onError !== undefined)
|
|
@@ -820,6 +858,9 @@ export class Server {
|
|
|
820
858
|
// Boot-time guard: the WS runtime is a subpath (`@nifrajs/core/ws`) so no-WebSocket apps don't
|
|
821
859
|
// bundle it. Registration is the loud, early failure point - never the first connection.
|
|
822
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
|
+
}
|
|
823
864
|
this.topics ??= runtime.createTopics();
|
|
824
865
|
// A `messageSchema` wraps `message` with validation once, here - every adapter then dispatches
|
|
825
866
|
// already-validated, typed messages (Bun/Deno/Node/Workers) with no per-adapter code.
|
|
@@ -872,6 +913,8 @@ export class Server {
|
|
|
872
913
|
}
|
|
873
914
|
prepareRoute(method, path, schema, handler) {
|
|
874
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.
|
|
875
918
|
let bodyLimit = this.maxBodyBytes;
|
|
876
919
|
const invalidBodyLimit = (message) => {
|
|
877
920
|
throw new RouteConfigError("INVALID_BODY_LIMIT", `route ${method} ${path}: ${message}`);
|
|
@@ -884,6 +927,12 @@ export class Server {
|
|
|
884
927
|
schema.bodyLimitReason.trim().length === 0) {
|
|
885
928
|
invalidBodyLimit('bodyLimit: "unlimited" requires a non-empty bodyLimitReason');
|
|
886
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
|
+
}
|
|
887
936
|
bodyLimit = undefined;
|
|
888
937
|
}
|
|
889
938
|
else if (schema?.bodyLimit !== undefined) {
|
|
@@ -981,12 +1030,12 @@ export class Server {
|
|
|
981
1030
|
// handler continuation once. The runner keeps the bounded parser and all error semantics while
|
|
982
1031
|
// avoiding the generic entry/schema/lifecycle dispatch on Bun, Deno, and Node-direct.
|
|
983
1032
|
const fusedBodyRunner = fusedBody
|
|
984
|
-
? this.buildFusedBodyRunner(handler, schema.body, hasDecorations ? routeDecorations : undefined, bodyLimit ??
|
|
1033
|
+
? this.buildFusedBodyRunner(handler, schema.body, hasDecorations ? routeDecorations : undefined, bodyLimit ?? UNLIMITED_BODY_BYTES)
|
|
985
1034
|
: undefined;
|
|
986
1035
|
const fusedWeb = bare && this.aroundHooks.length === 0
|
|
987
|
-
? this.buildFusedWeb(handler, hasDecorations ? routeDecorations : undefined, isContextlessNoArgArrow(handler), bodyLimit ??
|
|
1036
|
+
? this.buildFusedWeb(handler, hasDecorations ? routeDecorations : undefined, isContextlessNoArgArrow(handler), bodyLimit ?? UNLIMITED_BODY_BYTES)
|
|
988
1037
|
: fusedQuery
|
|
989
|
-
? this.buildFusedQueryWeb(handler, hasDecorations ? routeDecorations : undefined, schema.query, bodyLimit ??
|
|
1038
|
+
? this.buildFusedQueryWeb(handler, hasDecorations ? routeDecorations : undefined, schema.query, bodyLimit ?? UNLIMITED_BODY_BYTES)
|
|
990
1039
|
: fusedBody
|
|
991
1040
|
? this.buildFusedBodyWeb(fusedBodyRunner)
|
|
992
1041
|
: undefined;
|
|
@@ -1094,7 +1143,18 @@ export class Server {
|
|
|
1094
1143
|
});
|
|
1095
1144
|
routeAssurance.push(Object.freeze(declared));
|
|
1096
1145
|
}
|
|
1097
|
-
|
|
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")) {
|
|
1098
1158
|
routeAssurance.push(Object.freeze({
|
|
1099
1159
|
id: NIFRA_ASSURANCE_IDS.BODY_BOUNDED,
|
|
1100
1160
|
source: "route-schema",
|
|
@@ -1129,6 +1189,29 @@ export class Server {
|
|
|
1129
1189
|
this.assertConfigurable("responseContract()");
|
|
1130
1190
|
this.responseContractRuntime = runtime;
|
|
1131
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
|
+
}
|
|
1132
1215
|
/** @internal Symbol-keyed install seam for the `idempotency()` plugin. Off the public typed surface. */
|
|
1133
1216
|
[INSTALL_IDEMPOTENCY](runtime) {
|
|
1134
1217
|
this.assertConfigurable("idempotency()");
|
|
@@ -1259,13 +1342,13 @@ export class Server {
|
|
|
1259
1342
|
// Rebuild with the SAME builder that produced the closure - rebinding a query-fused route as
|
|
1260
1343
|
// bare would silently drop its validation.
|
|
1261
1344
|
const fusedBody = entry.execution.fusedLane === "body"
|
|
1262
|
-
? 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)
|
|
1263
1346
|
: undefined;
|
|
1264
1347
|
const fusedWeb = entry.execution.fusedLane === "body"
|
|
1265
1348
|
? this.buildFusedBodyWeb(fusedBody)
|
|
1266
1349
|
: entry.execution.fusedLane === "query"
|
|
1267
|
-
? this.buildFusedQueryWeb(entry.handler, entry.hasDecorations ? entry.decorations : undefined, entry.schema?.query, entry.bodyLimit ??
|
|
1268
|
-
: 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);
|
|
1269
1352
|
return {
|
|
1270
1353
|
...route,
|
|
1271
1354
|
entry: {
|
|
@@ -1305,6 +1388,10 @@ export class Server {
|
|
|
1305
1388
|
return this.fetchSource(req, platform);
|
|
1306
1389
|
}
|
|
1307
1390
|
fetchSource(source, platform) {
|
|
1391
|
+
// Seal once on the first request so test servers (no `listen()`) still get the dead-hook check.
|
|
1392
|
+
// After that it is a single boolean read - the per-request cost the option promises to keep at zero.
|
|
1393
|
+
if (hookAuditRuntime && process.env.NODE_ENV !== "production")
|
|
1394
|
+
sealHookAudit(this, this.catalog.size, this.logger);
|
|
1308
1395
|
// Off path (default): straight through - one property check, no closure, no promise.
|
|
1309
1396
|
if (this.capacityGate === undefined)
|
|
1310
1397
|
return this.fetchSourceInner(source, platform);
|
|
@@ -1379,6 +1466,42 @@ export class Server {
|
|
|
1379
1466
|
release();
|
|
1380
1467
|
return outcome;
|
|
1381
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
|
+
}
|
|
1382
1505
|
/**
|
|
1383
1506
|
* Like {@link fetch}, but renders a plain-data result **without** building a Web `Response` - the
|
|
1384
1507
|
* `@nifrajs/node` adapter serializes the returned primitives straight to the socket, skipping the undici
|
|
@@ -1391,6 +1514,9 @@ export class Server {
|
|
|
1391
1514
|
return this.resolveNodeSource(req, platform);
|
|
1392
1515
|
}
|
|
1393
1516
|
resolveNodeSource(source, platform, suppliedRuntime) {
|
|
1517
|
+
// Seal once on the Node-direct lane too - it can bypass `fetchSource` entirely (hookless outcome).
|
|
1518
|
+
if (hookAuditRuntime && process.env.NODE_ENV !== "production")
|
|
1519
|
+
sealHookAudit(this, this.catalog.size, this.logger);
|
|
1394
1520
|
// A paired header-only native hook can preserve the Node-direct JSON/body outcome for successful
|
|
1395
1521
|
// responses. Arbitrary onResponse transforms need a real Web Response, but a buffered outcome can
|
|
1396
1522
|
// be materialized with a direct-write marker and return to the socket path when the hook mutates
|
|
@@ -1915,15 +2041,18 @@ export class Server {
|
|
|
1915
2041
|
: wrapResponse(mounted);
|
|
1916
2042
|
}
|
|
1917
2043
|
if (match.reason === "method-not-allowed") {
|
|
1918
|
-
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(", ") }));
|
|
1919
2048
|
}
|
|
1920
|
-
return wrapResponse(
|
|
2049
|
+
return wrapResponse(plainError(404, "not_found"));
|
|
1921
2050
|
}
|
|
1922
2051
|
// Inspect only captured values for escapes. Scanning the full pathname repeated work the router
|
|
1923
2052
|
// already did and made every plain dynamic route pay for unrelated static path bytes.
|
|
1924
2053
|
const params = match.params === EMPTY_PARAMS ? match.params : decodeRouteParams(match.params);
|
|
1925
2054
|
if (params === null) {
|
|
1926
|
-
return wrapResponse(
|
|
2055
|
+
return wrapResponse(plainError(400, "malformed_path"));
|
|
1927
2056
|
}
|
|
1928
2057
|
return this.runMatched(source, platform, match.payload, params, search, finalize, wrapResponse, onTimeout, webFast);
|
|
1929
2058
|
}
|
|
@@ -1958,7 +2087,7 @@ export class Server {
|
|
|
1958
2087
|
// The runtime is always present when a route resolved idempotency (enforced at registration).
|
|
1959
2088
|
if (entry.idempotent !== undefined && this.idempotencyRuntime !== undefined) {
|
|
1960
2089
|
return this.idempotencyRuntime.run(entry.idempotent, requestOf(source), platform, entry, params, search, wrapResponse, {
|
|
1961
|
-
maxBodyBytes: entry.bodyLimit ??
|
|
2090
|
+
maxBodyBytes: entry.bodyLimit ?? UNLIMITED_BODY_BYTES,
|
|
1962
2091
|
runLanes: (buffered, plat, ent, prm, srch) => this.idempotencyRunLanes(buffered, plat, ent, prm, srch),
|
|
1963
2092
|
});
|
|
1964
2093
|
}
|
|
@@ -1984,7 +2113,7 @@ export class Server {
|
|
|
1984
2113
|
? undefined
|
|
1985
2114
|
: admitDeadline(source.headers, this.deadlineAdmissionOptions);
|
|
1986
2115
|
if (admission !== undefined && !admission.ok) {
|
|
1987
|
-
return wrapResponse(
|
|
2116
|
+
return wrapResponse(plainError(admission.status, admission.reason));
|
|
1988
2117
|
}
|
|
1989
2118
|
const effectiveTimeoutMs = admission?.timeoutMs ?? 0;
|
|
1990
2119
|
// Only allocate a controller for a finite budget; the historical no-timeout path remains
|
|
@@ -2009,7 +2138,7 @@ export class Server {
|
|
|
2009
2138
|
// already complete and can't time out, so it's returned as-is (no 503 race, no promise).
|
|
2010
2139
|
if (controller !== undefined && outcome instanceof Promise) {
|
|
2011
2140
|
const timedOut = admission?.inherited === true
|
|
2012
|
-
? () => wrapResponse(
|
|
2141
|
+
? () => wrapResponse(plainError(504, "deadline_exceeded"))
|
|
2013
2142
|
: onTimeout;
|
|
2014
2143
|
return this.withTimeout(outcome, controller, timedOut, Math.max(0, Math.ceil(budget.remaining())));
|
|
2015
2144
|
}
|
|
@@ -2040,9 +2169,13 @@ export class Server {
|
|
|
2040
2169
|
contextlessBareError(err, source, params, search, signal, budget, platform, wrapResponse) {
|
|
2041
2170
|
if (err instanceof Response)
|
|
2042
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);
|
|
2043
2176
|
const ctx = new RequestContext(source, params, search, signal, budget, platform, this.maxBodyBytes, this.protoPoisoning);
|
|
2044
2177
|
this.logRequestError(err, ctx);
|
|
2045
|
-
return wrapResponse(
|
|
2178
|
+
return wrapResponse(plainError(500, "internal_error"));
|
|
2046
2179
|
}
|
|
2047
2180
|
/**
|
|
2048
2181
|
* The synchronous fast path selected by a route's execution plan: apply static decorations, call the
|
|
@@ -2062,11 +2195,11 @@ export class Server {
|
|
|
2062
2195
|
result = entry.handler(ctx);
|
|
2063
2196
|
}
|
|
2064
2197
|
catch (err) {
|
|
2065
|
-
return this.bareError(err, ctx, wrapResponse);
|
|
2198
|
+
return this.bareError(err, ctx, finalize, wrapResponse);
|
|
2066
2199
|
}
|
|
2067
2200
|
if (result instanceof Promise) {
|
|
2068
2201
|
// Async handler on an otherwise-bare route: finish on a microtask, with the same error handling.
|
|
2069
|
-
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));
|
|
2070
2203
|
}
|
|
2071
2204
|
return finalize(result, responseSet(ctx));
|
|
2072
2205
|
}
|
|
@@ -2080,14 +2213,10 @@ export class Server {
|
|
|
2080
2213
|
* 500s), same respond semantics (the lifecycle parity suite pins it).
|
|
2081
2214
|
*/
|
|
2082
2215
|
buildFusedWeb(handler, decorations, contextless, maxBodyBytes) {
|
|
2083
|
-
//
|
|
2084
|
-
//
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
return this.wrapWebResponse(err);
|
|
2088
|
-
this.logRequestError(err, ctx);
|
|
2089
|
-
return this.wrapWebResponse(jsonError(500, "internal_error"));
|
|
2090
|
-
};
|
|
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);
|
|
2091
2220
|
if (contextless && decorations === undefined) {
|
|
2092
2221
|
// `() => ...` can't observe the context - skip allocating one entirely (errors still build
|
|
2093
2222
|
// one for the structured log, exactly like runContextlessBare).
|
|
@@ -2133,7 +2262,7 @@ export class Server {
|
|
|
2133
2262
|
* bounded parser remains shared with the generic lane; only the route-invariant entry lookups and
|
|
2134
2263
|
* lifecycle dispatch disappear from the common synchronous-validator/synchronous-handler case. */
|
|
2135
2264
|
buildFusedBodyRunner(handler, bodySchema, decorations, maxBodyBytes) {
|
|
2136
|
-
const logError = (err, ctx, wrapResponse) => this.bareError(err, ctx, wrapResponse);
|
|
2265
|
+
const logError = (err, ctx, finalize, wrapResponse) => this.bareError(err, ctx, finalize, wrapResponse);
|
|
2137
2266
|
const runHandler = (ctx, finalize, wrapResponse) => {
|
|
2138
2267
|
if (decorations !== undefined)
|
|
2139
2268
|
Object.assign(ctx, decorations);
|
|
@@ -2142,7 +2271,7 @@ export class Server {
|
|
|
2142
2271
|
output = handler(ctx);
|
|
2143
2272
|
}
|
|
2144
2273
|
catch (err) {
|
|
2145
|
-
return logError(err, ctx, wrapResponse);
|
|
2274
|
+
return logError(err, ctx, finalize, wrapResponse);
|
|
2146
2275
|
}
|
|
2147
2276
|
if (output instanceof Promise) {
|
|
2148
2277
|
return output.then((result) => {
|
|
@@ -2150,20 +2279,20 @@ export class Server {
|
|
|
2150
2279
|
return finalize(result, responseSet(ctx), ctx);
|
|
2151
2280
|
}
|
|
2152
2281
|
catch (err) {
|
|
2153
|
-
return logError(err, ctx, wrapResponse);
|
|
2282
|
+
return logError(err, ctx, finalize, wrapResponse);
|
|
2154
2283
|
}
|
|
2155
|
-
}, (err) => logError(err, ctx, wrapResponse));
|
|
2284
|
+
}, (err) => logError(err, ctx, finalize, wrapResponse));
|
|
2156
2285
|
}
|
|
2157
2286
|
try {
|
|
2158
2287
|
return finalize(output, responseSet(ctx), ctx);
|
|
2159
2288
|
}
|
|
2160
2289
|
catch (err) {
|
|
2161
|
-
return logError(err, ctx, wrapResponse);
|
|
2290
|
+
return logError(err, ctx, finalize, wrapResponse);
|
|
2162
2291
|
}
|
|
2163
2292
|
};
|
|
2164
2293
|
const runValidated = (result, ctx, finalize, wrapResponse) => {
|
|
2165
2294
|
if (result.issues !== undefined)
|
|
2166
|
-
return wrapResponse(
|
|
2295
|
+
return wrapResponse(plainValidationError(result.issues));
|
|
2167
2296
|
ctx.body = result.value;
|
|
2168
2297
|
return runHandler(ctx, finalize, wrapResponse);
|
|
2169
2298
|
};
|
|
@@ -2173,7 +2302,7 @@ export class Server {
|
|
|
2173
2302
|
validation = bodySchema["~standard"].validate(parsed);
|
|
2174
2303
|
}
|
|
2175
2304
|
catch (err) {
|
|
2176
|
-
return logError(err, ctx, wrapResponse);
|
|
2305
|
+
return logError(err, ctx, finalize, wrapResponse);
|
|
2177
2306
|
}
|
|
2178
2307
|
if (validation instanceof Promise) {
|
|
2179
2308
|
return validation.then((settled) => {
|
|
@@ -2181,15 +2310,15 @@ export class Server {
|
|
|
2181
2310
|
return runValidated(settled, ctx, finalize, wrapResponse);
|
|
2182
2311
|
}
|
|
2183
2312
|
catch (err) {
|
|
2184
|
-
return logError(err, ctx, wrapResponse);
|
|
2313
|
+
return logError(err, ctx, finalize, wrapResponse);
|
|
2185
2314
|
}
|
|
2186
|
-
}, (err) => logError(err, ctx, wrapResponse));
|
|
2315
|
+
}, (err) => logError(err, ctx, finalize, wrapResponse));
|
|
2187
2316
|
}
|
|
2188
2317
|
try {
|
|
2189
2318
|
return runValidated(validation, ctx, finalize, wrapResponse);
|
|
2190
2319
|
}
|
|
2191
2320
|
catch (err) {
|
|
2192
|
-
return logError(err, ctx, wrapResponse);
|
|
2321
|
+
return logError(err, ctx, finalize, wrapResponse);
|
|
2193
2322
|
}
|
|
2194
2323
|
};
|
|
2195
2324
|
return (source, params, search, signal, budget, platform, nativeContext, finalize, wrapResponse) => {
|
|
@@ -2197,7 +2326,7 @@ export class Server {
|
|
|
2197
2326
|
? RequestContext.native(source, params, search, maxBodyBytes, platform, this.protoPoisoning)
|
|
2198
2327
|
: new RequestContext(source, params, search, signal, budget, platform, maxBodyBytes, this.protoPoisoning);
|
|
2199
2328
|
const finish = (value) => runParsed(value, ctx, finalize, wrapResponse);
|
|
2200
|
-
return
|
|
2329
|
+
return readBodyFramed(source, maxBodyBytes, this.protoPoisoning, finish, wrapResponse, (err) => logError(err, ctx, finalize, wrapResponse));
|
|
2201
2330
|
};
|
|
2202
2331
|
}
|
|
2203
2332
|
/** Web adapter wrapper for the shared body runner. Node passes its native finalizer directly through
|
|
@@ -2213,14 +2342,10 @@ export class Server {
|
|
|
2213
2342
|
* `Response` passes through, anything else logs and returns a flat 500, and an async validator
|
|
2214
2343
|
* falls to a then-chain with the same steps. */
|
|
2215
2344
|
buildFusedQueryWeb(handler, decorations, querySchema, maxBodyBytes) {
|
|
2216
|
-
//
|
|
2217
|
-
//
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
return this.wrapWebResponse(err);
|
|
2221
|
-
this.logRequestError(err, ctx);
|
|
2222
|
-
return this.wrapWebResponse(jsonError(500, "internal_error"));
|
|
2223
|
-
};
|
|
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);
|
|
2224
2349
|
const runHandler = (ctx, value) => {
|
|
2225
2350
|
ctx.query = value;
|
|
2226
2351
|
let result;
|
|
@@ -2250,19 +2375,19 @@ export class Server {
|
|
|
2250
2375
|
}
|
|
2251
2376
|
if (validation instanceof Promise) {
|
|
2252
2377
|
return validation.then((settled) => settled.issues !== undefined
|
|
2253
|
-
? this.wrapWebResponse(
|
|
2378
|
+
? this.wrapWebResponse(plainValidationError(settled.issues))
|
|
2254
2379
|
: runHandler(ctx, settled.value), (err) => logError(err, ctx));
|
|
2255
2380
|
}
|
|
2256
2381
|
if (validation.issues !== undefined)
|
|
2257
|
-
return this.wrapWebResponse(
|
|
2382
|
+
return this.wrapWebResponse(plainValidationError(validation.issues));
|
|
2258
2383
|
return runHandler(ctx, validation.value);
|
|
2259
2384
|
};
|
|
2260
2385
|
}
|
|
2261
|
-
bareError(err, ctx, wrapResponse) {
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
2265
|
-
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));
|
|
2266
2391
|
}
|
|
2267
2392
|
// @ts-expect-error TS6133 -- invoked structurally by compiled plans (internal/route-execution.ts)
|
|
2268
2393
|
// biome-ignore lint/correctness/noUnusedPrivateClassMembers: invoked structurally by compiled plans (internal/route-execution.ts)
|
|
@@ -2296,27 +2421,7 @@ export class Server {
|
|
|
2296
2421
|
// @ts-expect-error TS6133 -- invoked structurally by compiled plans (internal/route-execution.ts)
|
|
2297
2422
|
// biome-ignore lint/correctness/noUnusedPrivateClassMembers: invoked structurally by compiled plans (internal/route-execution.ts)
|
|
2298
2423
|
runBodyOnly(entry, source, ctx, finalize, wrapResponse) {
|
|
2299
|
-
return
|
|
2300
|
-
}
|
|
2301
|
-
/** Shared bounded body framing/parser. Both body lanes use the same trust-boundary checks; only
|
|
2302
|
-
* the validation + handler continuation differs. */
|
|
2303
|
-
readBodyInput(source, maxBodyBytes, onParsed, wrapResponse, onError) {
|
|
2304
|
-
const contentType = headerOf(source, "content-type") ?? "";
|
|
2305
|
-
if (contentType !== "application/json" && !contentType.includes("application/json")) {
|
|
2306
|
-
if (isUrlEncodedForm(contentType)) {
|
|
2307
|
-
return readBoundedForm(source, maxBodyBytes).then((form) => (form instanceof Response ? wrapResponse(form) : onParsed(form)), onError);
|
|
2308
|
-
}
|
|
2309
|
-
return Promise.resolve(wrapResponse(jsonError(415, "unsupported_media_type")));
|
|
2310
|
-
}
|
|
2311
|
-
// All JSON bodies go through readBoundedJson - the single enforcement point for the framed
|
|
2312
|
-
// fast path (post-read byte re-check), the streaming cap, and the prototype-poisoning guard.
|
|
2313
|
-
// A separate inlined native-json() fast path here would fork the trust boundary in two.
|
|
2314
|
-
try {
|
|
2315
|
-
return this.readBoundedJson(source, maxBodyBytes, (parsed) => (parsed instanceof Response ? wrapResponse(parsed) : onParsed(parsed)), onError);
|
|
2316
|
-
}
|
|
2317
|
-
catch (err) {
|
|
2318
|
-
return Promise.resolve(onError(err));
|
|
2319
|
-
}
|
|
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));
|
|
2320
2425
|
}
|
|
2321
2426
|
/** Validate + run the handler for the bodyOnly path - shared by the inline fast path and the
|
|
2322
2427
|
* streaming fallback. A method (not per-request closures) so the hot path allocates nothing
|
|
@@ -2371,13 +2476,13 @@ export class Server {
|
|
|
2371
2476
|
if (validation instanceof Promise) {
|
|
2372
2477
|
return validation.then((settled) => {
|
|
2373
2478
|
if (settled.issues !== undefined)
|
|
2374
|
-
return wrapResponse(
|
|
2479
|
+
return wrapResponse(plainValidationError(settled.issues));
|
|
2375
2480
|
ctx.body = settled.value;
|
|
2376
2481
|
return this.executeHandler(entry, ctx, finalize);
|
|
2377
2482
|
});
|
|
2378
2483
|
}
|
|
2379
2484
|
if (validation.issues !== undefined)
|
|
2380
|
-
return wrapResponse(
|
|
2485
|
+
return wrapResponse(plainValidationError(validation.issues));
|
|
2381
2486
|
ctx.body = validation.value;
|
|
2382
2487
|
return this.executeHandler(entry, ctx, finalize);
|
|
2383
2488
|
}
|
|
@@ -2386,18 +2491,18 @@ export class Server {
|
|
|
2386
2491
|
if (validation instanceof Promise) {
|
|
2387
2492
|
return validation.then((settled) => {
|
|
2388
2493
|
if (settled.issues !== undefined)
|
|
2389
|
-
return wrapResponse(
|
|
2494
|
+
return wrapResponse(plainValidationError(settled.issues));
|
|
2390
2495
|
ctx.query = settled.value;
|
|
2391
2496
|
return this.executeHandler(entry, ctx, finalize, wrapResponse);
|
|
2392
2497
|
}, (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse));
|
|
2393
2498
|
}
|
|
2394
2499
|
if (validation.issues !== undefined)
|
|
2395
|
-
return wrapResponse(
|
|
2500
|
+
return wrapResponse(plainValidationError(validation.issues));
|
|
2396
2501
|
ctx.query = validation.value;
|
|
2397
2502
|
return this.executeHandler(entry, ctx, finalize, wrapResponse);
|
|
2398
2503
|
}
|
|
2399
2504
|
}
|
|
2400
|
-
return wrapResponse(
|
|
2505
|
+
return wrapResponse(plainValidationError(originalIssues));
|
|
2401
2506
|
}
|
|
2402
2507
|
applyBodyValidation(entry, result, ctx, finalize, wrapResponse) {
|
|
2403
2508
|
if (result.issues !== undefined) {
|
|
@@ -2409,7 +2514,7 @@ export class Server {
|
|
|
2409
2514
|
}
|
|
2410
2515
|
return this.handleValidationErrorRecovery(entry, recovery, ctx, finalize, wrapResponse, result.issues, "body");
|
|
2411
2516
|
}
|
|
2412
|
-
return wrapResponse(
|
|
2517
|
+
return wrapResponse(plainValidationError(result.issues));
|
|
2413
2518
|
}
|
|
2414
2519
|
ctx.body = result.value;
|
|
2415
2520
|
return this.executeHandler(entry, ctx, finalize);
|
|
@@ -2452,7 +2557,7 @@ export class Server {
|
|
|
2452
2557
|
}
|
|
2453
2558
|
return this.handleValidationErrorRecovery(entry, recovery, ctx, finalize, wrapResponse, result.issues, "query");
|
|
2454
2559
|
}
|
|
2455
|
-
return wrapResponse(
|
|
2560
|
+
return wrapResponse(plainValidationError(result.issues));
|
|
2456
2561
|
}
|
|
2457
2562
|
ctx.query = result.value;
|
|
2458
2563
|
return this.executeHandler(entry, ctx, finalize, wrapResponse);
|
|
@@ -2747,6 +2852,8 @@ export class Server {
|
|
|
2747
2852
|
if (extension instanceof Promise) {
|
|
2748
2853
|
return this.continueLifecycleAfterDerive(entry, ctx, finalize, wrapResponse, i, extension);
|
|
2749
2854
|
}
|
|
2855
|
+
if (isDeriveEarlyExit(extension))
|
|
2856
|
+
return finalize(extension, responseSet(ctx));
|
|
2750
2857
|
Object.assign(ctx, extension);
|
|
2751
2858
|
}
|
|
2752
2859
|
for (let i = 0; i < entry.beforeHandle.length; i++) {
|
|
@@ -2774,6 +2881,8 @@ export class Server {
|
|
|
2774
2881
|
if (extension instanceof Promise) {
|
|
2775
2882
|
return this.continueLifecycleAfterDerive(entry, ctx, finalize, wrapResponse, 0, extension);
|
|
2776
2883
|
}
|
|
2884
|
+
if (isDeriveEarlyExit(extension))
|
|
2885
|
+
return finalize(extension, responseSet(ctx));
|
|
2777
2886
|
Object.assign(ctx, extension);
|
|
2778
2887
|
const outcome = entry.beforeHandle[0](ctx);
|
|
2779
2888
|
if (outcome instanceof Promise) {
|
|
@@ -2802,10 +2911,16 @@ export class Server {
|
|
|
2802
2911
|
}
|
|
2803
2912
|
async continueLifecycleAfterDerive(entry, ctx, finalize, wrapResponse, firstIndex, first) {
|
|
2804
2913
|
try {
|
|
2805
|
-
|
|
2914
|
+
const settled = await first;
|
|
2915
|
+
if (isDeriveEarlyExit(settled))
|
|
2916
|
+
return finalize(settled, responseSet(ctx));
|
|
2917
|
+
Object.assign(ctx, settled);
|
|
2806
2918
|
for (let i = firstIndex + 1; i < entry.derives.length; i++) {
|
|
2807
|
-
const
|
|
2808
|
-
|
|
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);
|
|
2809
2924
|
}
|
|
2810
2925
|
return await this.runLifecycleHooksAsync(entry, ctx, finalize, wrapResponse);
|
|
2811
2926
|
}
|
|
@@ -2909,7 +3024,7 @@ export class Server {
|
|
|
2909
3024
|
path: pathnameOf(ctx.req.url),
|
|
2910
3025
|
detail: outcome.message,
|
|
2911
3026
|
});
|
|
2912
|
-
return wrapResponse(
|
|
3027
|
+
return wrapResponse(plainError(500, "internal_error"));
|
|
2913
3028
|
}
|
|
2914
3029
|
if (outcome.kind === "warn") {
|
|
2915
3030
|
this.logger.warn("response contract", {
|
|
@@ -2920,14 +3035,33 @@ export class Server {
|
|
|
2920
3035
|
}
|
|
2921
3036
|
return finalize(outcome.value, responseSet(ctx));
|
|
2922
3037
|
}
|
|
2923
|
-
|
|
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) {
|
|
2924
3044
|
// A *thrown* Response is deliberate control flow, not an error - a guard throws a redirect/401,
|
|
2925
3045
|
// an action throws an error page. Return it as-is (Remix/SvelteKit semantics); don't run onError
|
|
2926
3046
|
// or log it as a 500. This is what makes `throw redirect(...)` / `requireSession(...)` work from
|
|
2927
3047
|
// any handler or loader.
|
|
2928
3048
|
if (err instanceof Response)
|
|
2929
3049
|
return wrapResponse(err);
|
|
2930
|
-
//
|
|
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) {
|
|
2931
3065
|
// Indexed for the same reason as the `beforeHandle` chain above: the iterator would outlive
|
|
2932
3066
|
// the `await` and stay heap-allocated on both engines.
|
|
2933
3067
|
for (let i = 0; i < entry.onError.length; i++) {
|
|
@@ -2936,49 +3070,33 @@ export class Server {
|
|
|
2936
3070
|
if (handled !== undefined)
|
|
2937
3071
|
return finalize(handled, responseSet(ctx));
|
|
2938
3072
|
}
|
|
2939
|
-
// Never crash the server or leak internals. The client gets a flat 500; the detail goes to the
|
|
2940
|
-
// (redacting) logger. Body-read failures and around-hook failures land here too.
|
|
2941
3073
|
this.logRequestError(err, ctx);
|
|
2942
|
-
return wrapResponse(
|
|
3074
|
+
return wrapResponse(plainError(500, "internal_error"));
|
|
2943
3075
|
}
|
|
2944
3076
|
/** Log an unhandled request error to the (redacting) logger - shared by {@link runLifecycle} and the
|
|
2945
3077
|
* bare fast path ({@link bareError}) so both record the same fields. Never throws; never leaks. */
|
|
2946
3078
|
logRequestError(err, ctx) {
|
|
2947
|
-
|
|
2948
|
-
// the app's call (`errorLogDetail`). The default keeps it: a 500 with no message and no stack is
|
|
2949
|
-
// an incident nobody can diagnose, and the framework already ships the narrower instrument for
|
|
2950
|
-
// the leak - a redacting logger with `valuePatterns`. `"none"` is there for an untrusted sink.
|
|
2951
|
-
const detail = this.errorLogDetail;
|
|
2952
|
-
this.logger.error("unhandled request error", {
|
|
2953
|
-
method: ctx.req.method,
|
|
2954
|
-
path: pathnameOf(ctx.req.url),
|
|
2955
|
-
name: err instanceof Error ? err.name : "Error",
|
|
2956
|
-
// `detail`, not `message`: the logger uses `message` for its own first argument, so a field of
|
|
2957
|
-
// that name is silently overwritten and the thrown error's own text never reaches the sink. It
|
|
2958
|
-
// survived only incidentally inside `stack`, and was lost outright for a non-Error throw.
|
|
2959
|
-
...(detail === "none" ? {} : { detail: err instanceof Error ? err.message : String(err) }),
|
|
2960
|
-
...(detail === "full" && err instanceof Error ? { stack: err.stack } : {}),
|
|
2961
|
-
});
|
|
3079
|
+
emitRequestErrorLog(this.logger, this.errorLogDetail, err, ctx);
|
|
2962
3080
|
}
|
|
2963
3081
|
async readAndValidateBody(req, entry, ctx) {
|
|
2964
3082
|
const contentType = headerOf(req, "content-type") ?? "";
|
|
2965
3083
|
let parsed;
|
|
2966
3084
|
if (contentType === "application/json" || contentType.includes("application/json")) {
|
|
2967
|
-
const json = await this.readBoundedJson(req, entry.bodyLimit ??
|
|
2968
|
-
if (json
|
|
3085
|
+
const json = await this.readBoundedJson(req, entry.bodyLimit ?? UNLIMITED_BODY_BYTES);
|
|
3086
|
+
if (isResponseResult(json))
|
|
2969
3087
|
return json;
|
|
2970
3088
|
parsed = json;
|
|
2971
3089
|
}
|
|
2972
3090
|
else if (isUrlEncodedForm(contentType)) {
|
|
2973
|
-
const form = await readBoundedForm(req, entry.bodyLimit ??
|
|
2974
|
-
if (form
|
|
3091
|
+
const form = await readBoundedForm(req, entry.bodyLimit ?? UNLIMITED_BODY_BYTES);
|
|
3092
|
+
if (isResponseResult(form))
|
|
2975
3093
|
return form;
|
|
2976
3094
|
parsed = form;
|
|
2977
3095
|
}
|
|
2978
3096
|
else {
|
|
2979
3097
|
// multipart/form-data (file uploads) stays 415 on the schema path by design - files don't
|
|
2980
3098
|
// fit a value schema; use a schema-less route + @nifrajs/uploads helpers for those.
|
|
2981
|
-
return
|
|
3099
|
+
return plainError(415, "unsupported_media_type");
|
|
2982
3100
|
}
|
|
2983
3101
|
const validation = entry.schema.body["~standard"].validate(parsed);
|
|
2984
3102
|
const result = validation instanceof Promise ? await validation : validation;
|
|
@@ -3003,7 +3121,7 @@ export class Server {
|
|
|
3003
3121
|
}
|
|
3004
3122
|
const hook = entry.schema?.onValidationError ?? this.defaultOnValidationError;
|
|
3005
3123
|
if (hook === undefined)
|
|
3006
|
-
return
|
|
3124
|
+
return plainValidationError(result.issues);
|
|
3007
3125
|
const attempted = hook(result.issues, ctx, kind);
|
|
3008
3126
|
if (attempted instanceof Promise) {
|
|
3009
3127
|
return attempted.then((recovery) => this.finishLifecycleValidationRecovery(entry, kind, result.issues, recovery, assign));
|
|
@@ -3012,7 +3130,7 @@ export class Server {
|
|
|
3012
3130
|
}
|
|
3013
3131
|
finishLifecycleValidationRecovery(entry, kind, issues, recovery, assign) {
|
|
3014
3132
|
if (recovery === undefined)
|
|
3015
|
-
return
|
|
3133
|
+
return plainValidationError(issues);
|
|
3016
3134
|
if (recovery instanceof Response)
|
|
3017
3135
|
return recovery;
|
|
3018
3136
|
const schema = kind === "body"
|
|
@@ -3026,13 +3144,13 @@ export class Server {
|
|
|
3026
3144
|
if (retried instanceof Promise) {
|
|
3027
3145
|
return retried.then((settled) => {
|
|
3028
3146
|
if (settled.issues !== undefined)
|
|
3029
|
-
return
|
|
3147
|
+
return plainValidationError(settled.issues);
|
|
3030
3148
|
assign(settled.value);
|
|
3031
3149
|
return undefined;
|
|
3032
3150
|
});
|
|
3033
3151
|
}
|
|
3034
3152
|
if (retried.issues !== undefined)
|
|
3035
|
-
return
|
|
3153
|
+
return plainValidationError(retried.issues);
|
|
3036
3154
|
assign(retried.value);
|
|
3037
3155
|
return undefined;
|
|
3038
3156
|
}
|
|
@@ -3192,6 +3310,9 @@ export class Server {
|
|
|
3192
3310
|
// defined`. Exercised by the @nifrajs/deno suite (which runs on a non-Bun runtime).
|
|
3193
3311
|
throw new FrameworkError("BUN_REQUIRED", "listen() uses Bun.serve and runs only on Bun. Serve on Node with @nifrajs/node or on Deno with @nifrajs/deno, or hand app.fetch to any fetch-compatible runtime (Workers, etc.).");
|
|
3194
3312
|
}
|
|
3313
|
+
// Seal before Bun.serve: in `"error"` mode this throws instead of leaving a bound port behind.
|
|
3314
|
+
if (hookAuditRuntime && process.env.NODE_ENV !== "production")
|
|
3315
|
+
sealHookAudit(this, this.catalog.size, this.logger);
|
|
3195
3316
|
// Bun's `Server` is the concrete handle; we expose the stable `RunningServer`
|
|
3196
3317
|
// subset so the public types don't depend on the ambient `Bun` global. The cast
|
|
3197
3318
|
// bridges them - Bun's `.port` is `number | undefined` (undefined only for unix
|