@nifrajs/core 1.12.0 → 2.0.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 +10 -1
- package/dist/assurance.d.ts +11 -2
- package/dist/assurance.d.ts.map +1 -1
- package/dist/assurance.js +20 -0
- package/dist/assurance.js.map +1 -1
- package/dist/effect-ledger.d.ts +9 -0
- package/dist/effect-ledger.d.ts.map +1 -0
- package/dist/effect-ledger.js +8 -0
- package/dist/effect-ledger.js.map +1 -0
- package/dist/idempotency-plugin.d.ts +8 -0
- package/dist/idempotency-plugin.d.ts.map +1 -0
- package/dist/idempotency-plugin.js +8 -0
- package/dist/idempotency-plugin.js.map +1 -0
- package/dist/index.d.ts +6 -34
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -30
- package/dist/index.js.map +1 -1
- package/dist/mcp.d.ts +7 -0
- package/dist/mcp.d.ts.map +1 -0
- package/dist/mcp.js +56 -0
- package/dist/mcp.js.map +1 -0
- package/dist/node-direct.d.ts +5 -0
- package/dist/node-direct.d.ts.map +1 -0
- package/dist/node-direct.js +27 -0
- package/dist/node-direct.js.map +1 -0
- package/dist/reflection.d.ts +5 -0
- package/dist/reflection.d.ts.map +1 -1
- package/dist/reflection.js +2 -0
- package/dist/reflection.js.map +1 -1
- package/dist/router/pattern.d.ts +42 -0
- package/dist/router/pattern.d.ts.map +1 -0
- package/dist/router/pattern.js +127 -0
- package/dist/router/pattern.js.map +1 -0
- package/dist/router/router.d.ts +2 -1
- package/dist/router/router.d.ts.map +1 -1
- package/dist/router/router.js +11 -48
- package/dist/router/router.js.map +1 -1
- package/dist/server/client-ip.d.ts +35 -0
- package/dist/server/client-ip.d.ts.map +1 -0
- package/dist/server/client-ip.js +42 -0
- package/dist/server/client-ip.js.map +1 -0
- package/dist/server/context.d.ts +47 -2
- package/dist/server/context.d.ts.map +1 -1
- package/dist/server/contract.d.ts +37 -10
- package/dist/server/contract.d.ts.map +1 -1
- package/dist/server/contract.js +33 -8
- package/dist/server/contract.js.map +1 -1
- package/dist/server/http.d.ts +14 -0
- package/dist/server/http.d.ts.map +1 -0
- package/dist/server/http.js +45 -0
- package/dist/server/http.js.map +1 -0
- package/dist/server/idempotency-lane.d.ts +44 -0
- package/dist/server/idempotency-lane.d.ts.map +1 -0
- package/dist/server/idempotency-lane.js +181 -0
- package/dist/server/idempotency-lane.js.map +1 -0
- package/dist/server/install.d.ts +21 -0
- package/dist/server/install.d.ts.map +1 -0
- package/dist/server/install.js +21 -0
- package/dist/server/install.js.map +1 -0
- package/dist/server/ledger-lane.d.ts +53 -0
- package/dist/server/ledger-lane.d.ts.map +1 -0
- package/dist/server/ledger-lane.js +82 -0
- package/dist/server/ledger-lane.js.map +1 -0
- package/dist/server/mcp-hook.d.ts +28 -0
- package/dist/server/mcp-hook.d.ts.map +1 -0
- package/dist/server/mcp-hook.js +2 -0
- package/dist/server/mcp-hook.js.map +1 -0
- package/dist/server/node-outcome-hook.d.ts +9 -0
- package/dist/server/node-outcome-hook.d.ts.map +1 -0
- package/dist/server/node-outcome-hook.js +2 -0
- package/dist/server/node-outcome-hook.js.map +1 -0
- package/dist/server/node-outcome.d.ts +37 -0
- package/dist/server/node-outcome.d.ts.map +1 -0
- package/dist/server/node-outcome.js +75 -0
- package/dist/server/node-outcome.js.map +1 -0
- package/dist/server/plugin.d.ts +78 -0
- package/dist/server/plugin.d.ts.map +1 -0
- package/dist/server/plugin.js +57 -0
- package/dist/server/plugin.js.map +1 -0
- package/dist/server/query.d.ts +18 -0
- package/dist/server/query.d.ts.map +1 -0
- package/dist/server/query.js +102 -0
- package/dist/server/query.js.map +1 -0
- package/dist/server/registry.d.ts +31 -6
- package/dist/server/registry.d.ts.map +1 -1
- package/dist/server/request-context.d.ts +51 -0
- package/dist/server/request-context.d.ts.map +1 -0
- package/dist/server/request-context.js +184 -0
- package/dist/server/request-context.js.map +1 -0
- package/dist/server/respond.d.ts +17 -0
- package/dist/server/respond.d.ts.map +1 -0
- package/dist/server/respond.js +81 -0
- package/dist/server/respond.js.map +1 -0
- package/dist/server/runtime-core.d.ts +36 -0
- package/dist/server/runtime-core.d.ts.map +1 -0
- package/dist/server/runtime-core.js +51 -0
- package/dist/server/runtime-core.js.map +1 -0
- package/dist/server/server-types.d.ts +203 -0
- package/dist/server/server-types.d.ts.map +1 -0
- package/dist/server/server-types.js +2 -0
- package/dist/server/server-types.js.map +1 -0
- package/dist/server/server.d.ts +135 -358
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +476 -1065
- package/dist/server/server.js.map +1 -1
- package/dist/server/sse-hook.d.ts +10 -0
- package/dist/server/sse-hook.d.ts.map +1 -0
- package/dist/server/sse-hook.js +2 -0
- package/dist/server/sse-hook.js.map +1 -0
- package/dist/server/sse.d.ts +7 -23
- package/dist/server/sse.d.ts.map +1 -1
- package/dist/server/sse.js +18 -0
- package/dist/server/sse.js.map +1 -1
- package/dist/server/websocket.d.ts +17 -1
- package/dist/server/websocket.d.ts.map +1 -1
- package/dist/server/websocket.js.map +1 -1
- package/dist/server/ws-hook.d.ts +8 -11
- package/dist/server/ws-hook.d.ts.map +1 -1
- package/dist/server/ws-hook.js +7 -14
- package/dist/server/ws-hook.js.map +1 -1
- package/dist/server.d.ts +21 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +16 -0
- package/dist/server.js.map +1 -0
- package/dist/ws.d.ts +16 -7
- package/dist/ws.d.ts.map +1 -1
- package/dist/ws.js +25 -10
- package/dist/ws.js.map +1 -1
- package/package.json +82 -6
- package/src/assurance.ts +36 -1
- package/src/effect-ledger.ts +9 -0
- package/src/idempotency-plugin.ts +7 -0
- package/src/index.ts +6 -132
- package/src/mcp.ts +65 -0
- package/src/node-direct.ts +37 -0
- package/src/reflection.ts +7 -0
- package/src/router/pattern.ts +176 -0
- package/src/router/router.ts +11 -69
- package/src/server/client-ip.ts +60 -0
- package/src/server/context.ts +50 -2
- package/src/server/contract.ts +68 -25
- package/src/server/http.ts +58 -0
- package/src/server/idempotency-lane.ts +287 -0
- package/src/server/install.ts +28 -0
- package/src/server/ledger-lane.ts +125 -0
- package/src/server/mcp-hook.ts +41 -0
- package/src/server/node-outcome-hook.ts +9 -0
- package/src/server/node-outcome.ts +112 -0
- package/src/server/plugin.ts +92 -0
- package/src/server/query.ts +119 -0
- package/src/server/registry.ts +36 -6
- package/src/server/request-context.ts +271 -0
- package/src/server/respond.ts +93 -0
- package/src/server/runtime-core.ts +74 -0
- package/src/server/server-types.ts +204 -0
- package/src/server/server.ts +863 -1657
- package/src/server/sse-hook.ts +14 -0
- package/src/server/sse.ts +26 -0
- package/src/server/websocket.ts +20 -0
- package/src/server/ws-hook.ts +8 -19
- package/src/server.ts +92 -0
- package/src/ws.ts +33 -10
- package/dist/invariants.d.ts +0 -70
- package/dist/invariants.d.ts.map +0 -1
- package/dist/invariants.js +0 -487
- package/dist/invariants.js.map +0 -1
- package/src/invariants.ts +0 -604
package/dist/server/server.js
CHANGED
|
@@ -1,128 +1,103 @@
|
|
|
1
1
|
import { admitDeadline, createRequestBudget, createUnboundedRequestBudget, NIFRA_DEADLINE_HEADER, } from "../budget.js";
|
|
2
2
|
import { FrameworkError, RouteConfigError } from "../errors.js";
|
|
3
|
-
import { canonicalizeIdempotencyBody, computeIdempotencyFingerprint, DEFAULT_IDEMPOTENCY_HEADER, DEFAULT_IDEMPOTENCY_TTL_MS, IdempotencyResponseTooLargeError, MemoryIdempotencyStore, responseFromStored, serializeResponse, validIdempotencyKey, validIdempotencyNamespace, } from "../idempotency.js";
|
|
4
3
|
import { CAPABILITY_GUARD, createCapabilityGuard, normalizeRouteCapabilities, } from "../internal/capability-runtime.js";
|
|
5
|
-
import { assuranceDeclarationsOf, assuranceEvidenceFor, NIFRA_ASSURANCE_IDS, } from "../internal/route-assurance.js";
|
|
6
|
-
import {
|
|
4
|
+
import { assuranceDeclarationsOf, assuranceEvidenceFor, NIFRA_ASSURANCE_IDS, validEvidenceId, } from "../internal/route-assurance.js";
|
|
5
|
+
import { compileRoutePattern, decodeRouteParams, } from "../router/pattern.js";
|
|
7
6
|
import { EMPTY_PARAMS, Router } from "../router/router.js";
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
7
|
+
import { parseContentLength } from "./body.js";
|
|
8
|
+
import { resolveClientIp } from "./client-ip.js";
|
|
9
|
+
import { jsonError, pathnameOf, urlPartsOf } from "./http.js";
|
|
10
|
+
import { isUrlEncodedForm, queryObjectOf, readBoundedForm, searchOf, } from "./query.js";
|
|
11
|
+
import { RequestContext, readBoundedJsonSource } from "./request-context.js";
|
|
12
|
+
import { fusedRespond, fusedRespondNoSet, toResponse } from "./respond.js";
|
|
13
|
+
import { CONTEXT_SEARCH, CONTEXT_SET, EMPTY_RESPONSE_CONTROLS, getNeverAbortSignal, getUnboundedRequestBudget, headerOf, requestOf, } from "./runtime-core.js";
|
|
14
|
+
import { INSTALL_EFFECT_LEDGER, INSTALL_IDEMPOTENCY, INSTALL_MCP, INSTALL_NODE_DIRECT, INSTALL_SSE, INSTALL_WS, } from "./install.js";
|
|
10
15
|
import { jsonLogger } from "./logger.js";
|
|
11
|
-
import { sse, typedSSEStream } from "./sse.js";
|
|
12
|
-
import { getWsRuntime } from "./ws-hook.js";
|
|
13
|
-
/** The concrete `Request` for a source — itself when a real `Request` was passed (the Web path), or the
|
|
14
|
-
* lazily-built one (the Node adapter). A real `Request` IS a `RequestSource`, so no wrapper is allocated
|
|
15
|
-
* on the Web hot path. */
|
|
16
|
-
function requestOf(source) {
|
|
17
|
-
return source.request ?? source;
|
|
18
|
-
}
|
|
19
|
-
function headerOf(source, name) {
|
|
20
|
-
return source.header?.(name) ?? source.headers.get(name);
|
|
21
|
-
}
|
|
22
|
-
const RESPONSE_RESULT = Symbol.for("nifra.response.result");
|
|
23
|
-
const CONTEXT_SET = Symbol("nifra.context.set");
|
|
24
|
-
const CONTEXT_SEARCH = Symbol("nifra.context.search");
|
|
25
16
|
const functionToString = Function.prototype.toString;
|
|
26
17
|
const CONTEXTLESS_ARROW = /^(?:async\s*)?\(\s*\)\s*(?::[\s\S]*?)?=>/;
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
18
|
+
/**
|
|
19
|
+
* Runtime route catalog. Single-route registration mutates directly; multi-route registration replays
|
|
20
|
+
* the existing catalog plus the candidate batch into a staged router, then swaps the complete state only
|
|
21
|
+
* after every route validates. Failed `implement()`/`merge()` batches therefore leave matching and
|
|
22
|
+
* reflection unchanged.
|
|
23
|
+
*/
|
|
24
|
+
class RouteCatalog {
|
|
25
|
+
matcher = new Router();
|
|
26
|
+
records = [];
|
|
27
|
+
/** Allocation-free reflection view for the common no-assurance case. Derived only at commit time. */
|
|
28
|
+
descriptors = [];
|
|
29
|
+
assurancePresent = false;
|
|
30
|
+
add(route) {
|
|
31
|
+
this.matcher.add(route.method, route.pattern, route.entry);
|
|
32
|
+
this.records.push(route);
|
|
33
|
+
this.descriptors.push(route.descriptor);
|
|
34
|
+
if (route.assurance.length > 0)
|
|
35
|
+
this.assurancePresent = true;
|
|
36
|
+
}
|
|
37
|
+
addBatch(routes) {
|
|
38
|
+
if (routes.length === 0)
|
|
39
|
+
return;
|
|
40
|
+
const nextRecords = this.records.concat(routes);
|
|
41
|
+
const nextDescriptors = this.descriptors.concat(routes.map(({ descriptor }) => descriptor));
|
|
42
|
+
const nextAssurancePresent = this.assurancePresent || routes.some((route) => route.assurance.length > 0);
|
|
43
|
+
const staged = new Router();
|
|
44
|
+
for (const route of this.records)
|
|
45
|
+
staged.add(route.method, route.pattern, route.entry);
|
|
46
|
+
for (const route of routes)
|
|
47
|
+
staged.add(route.method, route.pattern, route.entry);
|
|
48
|
+
this.matcher = staged;
|
|
49
|
+
this.records = nextRecords;
|
|
50
|
+
this.descriptors = nextDescriptors;
|
|
51
|
+
this.assurancePresent = nextAssurancePresent;
|
|
52
|
+
}
|
|
53
|
+
find(method, path) {
|
|
54
|
+
return this.matcher.find(method, path);
|
|
55
|
+
}
|
|
56
|
+
entries() {
|
|
57
|
+
return this.records;
|
|
58
|
+
}
|
|
59
|
+
routeDescriptors() {
|
|
60
|
+
return this.descriptors;
|
|
61
|
+
}
|
|
62
|
+
lastDescriptor() {
|
|
63
|
+
return this.records[this.records.length - 1]?.descriptor;
|
|
64
|
+
}
|
|
65
|
+
hasAssurance() {
|
|
66
|
+
return this.assurancePresent;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/** The socket peer Bun observed, as a `Platform` for the request lifecycle (`undefined` if unknown).
|
|
70
|
+
* Typed structurally on `requestIP` alone so any Bun `Server` (WS or not) satisfies it. */
|
|
71
|
+
function bunPeerPlatform(server, req) {
|
|
72
|
+
const address = server.requestIP(req)?.address;
|
|
73
|
+
return address === undefined ? undefined : { clientIp: address };
|
|
32
74
|
}
|
|
33
|
-
/** Off-edge `waitUntil`: run the background work fire-and-forget, never leaking an unhandled
|
|
34
|
-
* rejection. Edge runtimes pass their own (Workers `ctx.waitUntil`) via the platform arg. */
|
|
35
|
-
const fallbackWaitUntil = (promise) => {
|
|
36
|
-
void promise.catch(() => { });
|
|
37
|
-
};
|
|
38
75
|
const WS_PASS = { kind: "pass" };
|
|
39
76
|
/** `app.ws()` (and everything downstream of it) needs the runtime `@nifrajs/core/ws` registers. */
|
|
40
|
-
function requireWsRuntime() {
|
|
41
|
-
const runtime = getWsRuntime();
|
|
77
|
+
function requireWsRuntime(runtime) {
|
|
42
78
|
if (runtime === undefined) {
|
|
43
|
-
throw new FrameworkError("WS_RUNTIME_MISSING",
|
|
79
|
+
throw new FrameworkError("WS_RUNTIME_MISSING", "app.ws() needs the WebSocket runtime, which ships as an opt-in plugin so no-WebSocket apps stay lean. Add `.use(websocket())` from `@nifrajs/core/ws` before declaring WS routes.");
|
|
44
80
|
}
|
|
45
81
|
return runtime;
|
|
46
82
|
}
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
* ```ts
|
|
53
|
-
* export const requestId = definePlugin("requestId", (app) => app.derive(() => ({ requestId: uuid() })))
|
|
54
|
-
* app.use(requestId) // downstream handlers see c.requestId
|
|
55
|
-
* ```
|
|
56
|
-
*
|
|
57
|
-
* FOOTGUN: only use this for a plugin that adds context. For a plugin that **mounts routes/hooks but adds
|
|
58
|
-
* NO context** (an auth router, an audit logger), use {@link defineRouterPlugin} ({@link defineIdentityPlugin}).
|
|
59
|
-
* `definePlugin((app) => app.get(...))` infers `app: Server<any, any>`, so `.use()` returns `Server<any, any>`
|
|
60
|
-
* and your whole typed client silently collapses to `any` — no type error, no runtime error.
|
|
61
|
-
*/
|
|
62
|
-
export function definePlugin(name, apply) {
|
|
63
|
-
return Object.assign(apply, { pluginName: name });
|
|
83
|
+
function requireSseRuntime(runtime) {
|
|
84
|
+
if (runtime === undefined) {
|
|
85
|
+
throw new FrameworkError("SSE_RUNTIME_MISSING", "app.sse() needs the streaming runtime, which ships as a subpath so non-SSE apps stay lean. Add `.use(streaming())` (from `@nifrajs/core/sse`) at your server setup.");
|
|
86
|
+
}
|
|
87
|
+
return runtime;
|
|
64
88
|
}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
*
|
|
71
|
-
* Why a dedicated helper: `definePlugin((app) => app)` infers `app: Server<any, any>`, so `use` returns
|
|
72
|
-
* `Server<any, any>` and the whole typed client collapses to `any`. The explicit generic return type here
|
|
73
|
-
* (which a plain `Object.assign` can't preserve) is what keeps `use` returning the precise server type.
|
|
74
|
-
*
|
|
75
|
-
* ```ts
|
|
76
|
-
* export const audit = defineIdentityPlugin("audit", (app) => app.onResponse(logResponse))
|
|
77
|
-
* const api = server().get("/a", h).use(audit).get("/b", h) // /a AND /b stay typed
|
|
78
|
-
* ```
|
|
79
|
-
*/
|
|
80
|
-
export function defineIdentityPlugin(name, apply) {
|
|
81
|
-
return Object.assign(apply, { pluginName: name });
|
|
89
|
+
function requireMcpRuntime(runtime) {
|
|
90
|
+
if (runtime === undefined) {
|
|
91
|
+
throw new FrameworkError("MCP_RUNTIME_MISSING", "MCP declarations ship as an opt-in runtime so ordinary HTTP apps stay lean. Add `.use(mcp())` (from `@nifrajs/core/mcp`) at your server setup.");
|
|
92
|
+
}
|
|
93
|
+
return runtime;
|
|
82
94
|
}
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
* {@link definePlugin} — for any such plugin, or the typed client silently collapses to `any`. The
|
|
87
|
-
* "identity" in {@link defineIdentityPlugin} refers to the type-identity it preserves; `defineRouterPlugin`
|
|
88
|
-
* is the same thing under a clearer name.
|
|
89
|
-
*
|
|
90
|
-
* Mount routes as a **side effect**, then return the app unchanged (registering via `.get`/`.post` would
|
|
91
|
-
* change the type away from the identity `S`; the mounted routes run but aren't in the caller's typed
|
|
92
|
-
* registry — that's the trade that keeps everything else typed):
|
|
93
|
-
*
|
|
94
|
-
* ```ts
|
|
95
|
-
* export const scim = defineRouterPlugin("scim", (app) => {
|
|
96
|
-
* app.get("/scim/v2/Users", listUsers) // side effect: mounted at runtime
|
|
97
|
-
* return app // return S unchanged → routes added after .use(scim) stay typed
|
|
98
|
-
* })
|
|
99
|
-
* const api = server().get("/a", h).use(scim).get("/b", h) // /a AND /b stay typed
|
|
100
|
-
* ```
|
|
101
|
-
*/
|
|
102
|
-
export const defineRouterPlugin = defineIdentityPlugin;
|
|
95
|
+
// Plugin definers + their types now live in `./plugin.ts`; re-exported here so `.use()` callers and
|
|
96
|
+
// existing importers keep resolving them from the server module.
|
|
97
|
+
export { defineIdentityPlugin, definePlugin, defineRouterPlugin, } from "./plugin.js";
|
|
103
98
|
const DEFAULT_MAX_BODY_BYTES = 1_000_000;
|
|
104
99
|
const DEFAULT_DRAIN_MS = 10_000;
|
|
105
100
|
const DRAIN_POLL_MS = 10;
|
|
106
|
-
const TEXT_DECODER = new TextDecoder();
|
|
107
|
-
/**
|
|
108
|
-
* Shared never-aborting signal for `ctx.signal` when no timeout is armed — created lazily and
|
|
109
|
-
* cached. NOT a module-scope `new AbortController()`: edge runtimes (Cloudflare workerd) forbid
|
|
110
|
-
* constructing one in global scope; the first request builds it inside the handler, then it's
|
|
111
|
-
* reused at zero per-request cost.
|
|
112
|
-
*/
|
|
113
|
-
let neverAbortSignal;
|
|
114
|
-
let unboundedRequestBudget;
|
|
115
|
-
const getNeverAbortSignal = () => {
|
|
116
|
-
neverAbortSignal ??= new AbortController().signal;
|
|
117
|
-
return neverAbortSignal;
|
|
118
|
-
};
|
|
119
|
-
const getUnboundedRequestBudget = () => {
|
|
120
|
-
unboundedRequestBudget ??= createUnboundedRequestBudget(getNeverAbortSignal());
|
|
121
|
-
return unboundedRequestBudget;
|
|
122
|
-
};
|
|
123
|
-
function jsonError(status, error, headers) {
|
|
124
|
-
return Response.json({ ok: false, error }, headers !== undefined ? { status, headers } : { status });
|
|
125
|
-
}
|
|
126
101
|
/** Same-origin check for a WebSocket handshake (CSWSH default): the `Origin`'s host[:port] must equal the
|
|
127
102
|
* request's own host (from `req.url`, which the runtime builds from the `Host` header). Scheme differs
|
|
128
103
|
* (ws↔http), so compare host only. An unparseable Origin is treated as NOT same-origin (rejected). */
|
|
@@ -141,156 +116,12 @@ function validationError(issues) {
|
|
|
141
116
|
});
|
|
142
117
|
return Response.json({ ok: false, error: "validation", issues: serialized }, { status: 422 });
|
|
143
118
|
}
|
|
144
|
-
//
|
|
145
|
-
//
|
|
146
|
-
|
|
147
|
-
// string
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
const start = schemeEnd === -1 ? url.indexOf("/") : url.indexOf("/", schemeEnd + 3);
|
|
151
|
-
if (start === -1)
|
|
152
|
-
return { pathname: "/", search: "" };
|
|
153
|
-
let pathEnd = url.length;
|
|
154
|
-
let searchStart = -1;
|
|
155
|
-
let searchEnd = url.length;
|
|
156
|
-
for (let i = start; i < url.length; i++) {
|
|
157
|
-
const c = url.charCodeAt(i);
|
|
158
|
-
if (c === 63 /* ? */ && searchStart === -1) {
|
|
159
|
-
pathEnd = i;
|
|
160
|
-
searchStart = i;
|
|
161
|
-
}
|
|
162
|
-
else if (c === 35 /* # */) {
|
|
163
|
-
if (searchStart === -1)
|
|
164
|
-
pathEnd = i;
|
|
165
|
-
searchEnd = i;
|
|
166
|
-
break;
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
return {
|
|
170
|
-
pathname: url.slice(start, pathEnd),
|
|
171
|
-
search: searchStart === -1 ? "" : url.slice(searchStart, searchEnd),
|
|
172
|
-
};
|
|
173
|
-
}
|
|
174
|
-
// Extract the pathname WITHOUT a full WHATWG `new URL(req.url)` parse. Kept as a public-ish helper
|
|
175
|
-
// for tests and callers that only need the path; the request hot path uses `urlPartsOf()` once.
|
|
176
|
-
export function pathnameOf(url) {
|
|
177
|
-
return urlPartsOf(url).pathname;
|
|
178
|
-
}
|
|
179
|
-
// The query string ("?a=1", or "" when absent) — for lazily building `c.query` only when read.
|
|
180
|
-
// The fragment (after the first `#`) bounds the search: a `?` that appears only inside the fragment
|
|
181
|
-
// is NOT a query (matches WHATWG). Fragments never reach the server in `req.url`, so this is purely
|
|
182
|
-
// for provable equivalence with `new URL(req.url).search`.
|
|
183
|
-
export function searchOf(url) {
|
|
184
|
-
return urlPartsOf(url).search;
|
|
185
|
-
}
|
|
186
|
-
/** Accumulate into a NULL-PROTOTYPE record (every call site below creates one): with no inherited
|
|
187
|
-
* `constructor`/`toString`/`__proto__` accessors, a hostile key is just an own data key — the
|
|
188
|
-
* promotion logic can't collide with `Object.prototype` members, and `__proto__` needs no special
|
|
189
|
-
* case (direct assignment on a null-proto object creates an own property). */
|
|
190
|
-
function setQueryValue(out, key, value) {
|
|
191
|
-
const existing = out[key];
|
|
192
|
-
if (existing === undefined) {
|
|
193
|
-
out[key] = value;
|
|
194
|
-
}
|
|
195
|
-
else if (typeof existing === "string") {
|
|
196
|
-
out[key] = [existing, value];
|
|
197
|
-
}
|
|
198
|
-
else {
|
|
199
|
-
existing.push(value);
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
|
-
function queryObjectFallback(search) {
|
|
203
|
-
// Manual iteration instead of Object.fromEntries: repeated keys must promote to arrays
|
|
204
|
-
// (fromEntries is last-wins), and __proto__ needs the same own-property guard.
|
|
205
|
-
const out = Object.create(null);
|
|
206
|
-
for (const [key, value] of new URLSearchParams(search)) {
|
|
207
|
-
setQueryValue(out, key, value);
|
|
208
|
-
}
|
|
209
|
-
return out;
|
|
210
|
-
}
|
|
211
|
-
/**
|
|
212
|
-
* Build the plain object passed to query schemas. Plain ASCII-ish queries avoid
|
|
213
|
-
* `URLSearchParams` + iterator allocations; encoded queries fall back to the Web API so `+`,
|
|
214
|
-
* percent-decoding, malformed escapes, and empty-key behavior stay exact. Repeated keys promote
|
|
215
|
-
* to `string[]` on BOTH paths (see {@link setQueryValue}).
|
|
216
|
-
*/
|
|
217
|
-
// Shared empty result for no-query requests: frozen + null-prototype (same shape contract as the
|
|
218
|
-
// populated path), allocated once instead of per request.
|
|
219
|
-
const EMPTY_QUERY = Object.freeze(Object.create(null));
|
|
220
|
-
export function queryObjectOf(search) {
|
|
221
|
-
const start = search.charCodeAt(0) === 63 /* ? */ ? 1 : 0;
|
|
222
|
-
if (start >= search.length)
|
|
223
|
-
return EMPTY_QUERY;
|
|
224
|
-
for (let i = start; i < search.length; i++) {
|
|
225
|
-
const c = search.charCodeAt(i);
|
|
226
|
-
if (c === 37 /* % */ || c === 43 /* + */)
|
|
227
|
-
return queryObjectFallback(search);
|
|
228
|
-
}
|
|
229
|
-
const out = Object.create(null);
|
|
230
|
-
let pos = start;
|
|
231
|
-
while (pos <= search.length) {
|
|
232
|
-
const amp = search.indexOf("&", pos);
|
|
233
|
-
const end = amp === -1 ? search.length : amp;
|
|
234
|
-
if (end > pos) {
|
|
235
|
-
const eq = search.indexOf("=", pos);
|
|
236
|
-
const split = eq !== -1 && eq < end ? eq : end;
|
|
237
|
-
const key = search.slice(pos, split);
|
|
238
|
-
const value = split === end ? "" : search.slice(split + 1, end);
|
|
239
|
-
setQueryValue(out, key, value);
|
|
240
|
-
}
|
|
241
|
-
if (amp === -1)
|
|
242
|
-
break;
|
|
243
|
-
pos = amp + 1;
|
|
244
|
-
}
|
|
245
|
-
return out;
|
|
246
|
-
}
|
|
247
|
-
/** `application/x-www-form-urlencoded`, with or without a charset suffix. */
|
|
248
|
-
function isUrlEncodedForm(contentType) {
|
|
249
|
-
return (contentType === "application/x-www-form-urlencoded" ||
|
|
250
|
-
contentType.startsWith("application/x-www-form-urlencoded;"));
|
|
251
|
-
}
|
|
252
|
-
const FORM_DECODER = new TextDecoder();
|
|
253
|
-
/**
|
|
254
|
-
* Read an HTML-form body (urlencoded) into the plain object a body schema validates — same byte
|
|
255
|
-
* cap as the JSON path (a lying/absent Content-Length can't force an oversized buffer), same
|
|
256
|
-
* repeated-key → `string[]` promotion as query parsing, same `__proto__` guard.
|
|
257
|
-
*/
|
|
258
|
-
async function readBoundedForm(req, maxBytes) {
|
|
259
|
-
const read = await readBoundedBytes(req, maxBytes);
|
|
260
|
-
if (!read.ok) {
|
|
261
|
-
return read.status === 413
|
|
262
|
-
? jsonError(413, "payload_too_large")
|
|
263
|
-
: jsonError(400, "invalid_content_length");
|
|
264
|
-
}
|
|
265
|
-
const out = Object.create(null);
|
|
266
|
-
// URLSearchParams owns the format's quirks (`+` as space, percent-decoding, empty keys);
|
|
267
|
-
// it never throws on junk input, so no try/catch is needed here.
|
|
268
|
-
for (const [key, value] of new URLSearchParams(FORM_DECODER.decode(read.bytes))) {
|
|
269
|
-
setQueryValue(out, key, value);
|
|
270
|
-
}
|
|
271
|
-
return out;
|
|
272
|
-
}
|
|
273
|
-
function decodeParams(raw) {
|
|
274
|
-
let out;
|
|
275
|
-
for (const key in raw) {
|
|
276
|
-
const value = raw[key];
|
|
277
|
-
// `decodeURIComponent` only changes strings containing `%`; for the common
|
|
278
|
-
// unencoded param (`/users/123`) skip the call and its throw-check entirely —
|
|
279
|
-
// semantically identical (no `%` ⇒ no escapes to decode), ~3× cheaper on that path. If every
|
|
280
|
-
// param is already plain, return the router's per-request object directly and skip a clone.
|
|
281
|
-
if (!value.includes("%")) {
|
|
282
|
-
continue;
|
|
283
|
-
}
|
|
284
|
-
try {
|
|
285
|
-
out ??= { ...raw };
|
|
286
|
-
out[key] = decodeURIComponent(value);
|
|
287
|
-
}
|
|
288
|
-
catch {
|
|
289
|
-
return null;
|
|
290
|
-
}
|
|
291
|
-
}
|
|
292
|
-
return out ?? raw;
|
|
293
|
-
}
|
|
119
|
+
// `jsonError`, `urlPartsOf`, `pathnameOf` moved to `./http.ts` (a dependency-free leaf shared with the
|
|
120
|
+
// opt-in request lanes); re-exported so existing importers keep resolving from here.
|
|
121
|
+
export { pathnameOf, urlPartsOf } from "./http.js";
|
|
122
|
+
// Query-string + urlencoded-form parsing now lives in `./query.ts`; re-exported so existing
|
|
123
|
+
// importers keep resolving `searchOf`/`queryObjectOf`/`QueryValue` from here.
|
|
124
|
+
export { queryObjectOf, searchOf };
|
|
294
125
|
function hasReplacementParam(params) {
|
|
295
126
|
for (const key in params) {
|
|
296
127
|
if (params[key].includes("\uFFFD"))
|
|
@@ -298,35 +129,6 @@ function hasReplacementParam(params) {
|
|
|
298
129
|
}
|
|
299
130
|
return false;
|
|
300
131
|
}
|
|
301
|
-
class LazyResponseControls {
|
|
302
|
-
status;
|
|
303
|
-
_headers;
|
|
304
|
-
_cookies;
|
|
305
|
-
get headers() {
|
|
306
|
-
this._headers ??= {};
|
|
307
|
-
return this._headers;
|
|
308
|
-
}
|
|
309
|
-
cookie(name, value, options) {
|
|
310
|
-
// Secure-by-default: HttpOnly + Secure + SameSite=Lax + Path=/, overridable per call.
|
|
311
|
-
const merged = {
|
|
312
|
-
httpOnly: true,
|
|
313
|
-
secure: true,
|
|
314
|
-
sameSite: "lax",
|
|
315
|
-
path: "/",
|
|
316
|
-
...options,
|
|
317
|
-
};
|
|
318
|
-
this._cookies ??= [];
|
|
319
|
-
this._cookies.push(serializeCookie(name, value, merged));
|
|
320
|
-
}
|
|
321
|
-
deleteCookie(name, options) {
|
|
322
|
-
// Expire immediately; default Path=/, and match the original path/domain or the browser keeps it.
|
|
323
|
-
this._cookies ??= [];
|
|
324
|
-
this._cookies.push(serializeCookie(name, "", { path: "/", ...options, maxAge: 0, expires: EPOCH }));
|
|
325
|
-
}
|
|
326
|
-
}
|
|
327
|
-
// Finalization only reads `status`, `_headers`, and `_cookies`. The user-visible mutator methods
|
|
328
|
-
// live on `LazyResponseControls`, created by the `c.set` getter only when user code touches it.
|
|
329
|
-
const EMPTY_RESPONSE_CONTROLS = Object.freeze({});
|
|
330
132
|
function responseSet(ctx) {
|
|
331
133
|
return ctx[CONTEXT_SET]() ?? EMPTY_RESPONSE_CONTROLS;
|
|
332
134
|
}
|
|
@@ -340,302 +142,9 @@ function isContextlessNoArgArrow(handler) {
|
|
|
340
142
|
return false;
|
|
341
143
|
}
|
|
342
144
|
}
|
|
343
|
-
/** Coerce `c.json`/`c.text`'s second arg — a status number (the common case) or a full `ResponseInit`. */
|
|
344
|
-
function statusInit(init) {
|
|
345
|
-
return typeof init === "number" ? { status: init } : init;
|
|
346
|
-
}
|
|
347
|
-
class RequestContext {
|
|
348
|
-
constructor(source, params, searchOrMaxBodyBytes, signal, budget, platform, maxBodyBytes) {
|
|
349
|
-
this.source = source;
|
|
350
|
-
this.params = params;
|
|
351
|
-
if (typeof searchOrMaxBodyBytes === "number") {
|
|
352
|
-
this.maxBodyBytes = searchOrMaxBodyBytes;
|
|
353
|
-
return;
|
|
354
|
-
}
|
|
355
|
-
if (searchOrMaxBodyBytes !== undefined)
|
|
356
|
-
this.searchValue = searchOrMaxBodyBytes;
|
|
357
|
-
this.signalValue = signal;
|
|
358
|
-
this.budgetValue = budget;
|
|
359
|
-
if (platform !== undefined)
|
|
360
|
-
this.platformValue = platform;
|
|
361
|
-
this.maxBodyBytes = maxBodyBytes;
|
|
362
|
-
}
|
|
363
|
-
static native(source, params, maxBodyBytes) {
|
|
364
|
-
return new RequestContext(source, params, maxBodyBytes);
|
|
365
|
-
}
|
|
366
|
-
[CONTEXT_SET]() {
|
|
367
|
-
return this.setValue;
|
|
368
|
-
}
|
|
369
|
-
get [CONTEXT_SEARCH]() {
|
|
370
|
-
this.searchValue ??= searchOf(this.source.url);
|
|
371
|
-
return this.searchValue;
|
|
372
|
-
}
|
|
373
|
-
get set() {
|
|
374
|
-
this.setValue ??= new LazyResponseControls();
|
|
375
|
-
return this.setValue;
|
|
376
|
-
}
|
|
377
|
-
get signal() {
|
|
378
|
-
return this.signalValue ?? getNeverAbortSignal();
|
|
379
|
-
}
|
|
380
|
-
get budget() {
|
|
381
|
-
return this.budgetValue ?? getUnboundedRequestBudget();
|
|
382
|
-
}
|
|
383
|
-
get env() {
|
|
384
|
-
return this.platformValue?.env;
|
|
385
|
-
}
|
|
386
|
-
get waitUntil() {
|
|
387
|
-
return this.platformValue?.waitUntil ?? fallbackWaitUntil;
|
|
388
|
-
}
|
|
389
|
-
get req() {
|
|
390
|
-
return requestOf(this.source);
|
|
391
|
-
}
|
|
392
|
-
get request() {
|
|
393
|
-
return requestOf(this.source);
|
|
394
|
-
}
|
|
395
|
-
json(body, init) {
|
|
396
|
-
return Response.json(body, statusInit(init));
|
|
397
|
-
}
|
|
398
|
-
text(body, init) {
|
|
399
|
-
const i = statusInit(init);
|
|
400
|
-
// Default to text/plain; if the caller passes their own headers, they own the content-type.
|
|
401
|
-
if (i?.headers !== undefined)
|
|
402
|
-
return new Response(body, i);
|
|
403
|
-
return new Response(body, { ...i, headers: { "content-type": "text/plain; charset=utf-8" } });
|
|
404
|
-
}
|
|
405
|
-
get query() {
|
|
406
|
-
if (!this.queryReady) {
|
|
407
|
-
this.queryValue = new URLSearchParams(this[CONTEXT_SEARCH]);
|
|
408
|
-
this.queryReady = true;
|
|
409
|
-
}
|
|
410
|
-
return this.queryValue;
|
|
411
|
-
}
|
|
412
|
-
set query(v) {
|
|
413
|
-
this.queryValue = v;
|
|
414
|
-
this.queryReady = true;
|
|
415
|
-
}
|
|
416
|
-
get cookies() {
|
|
417
|
-
this.cookiesValue ??= parseCookies(headerOf(this.source, "cookie"));
|
|
418
|
-
return this.cookiesValue;
|
|
419
|
-
}
|
|
420
|
-
boundedBody(maxBytes) {
|
|
421
|
-
return readBoundedBodyOrThrow(this.source, this.maxBodyBytes, maxBytes);
|
|
422
|
-
}
|
|
423
|
-
boundedJson(maxBytes) {
|
|
424
|
-
return readBoundedJsonBodyOrThrow(this.source, this.maxBodyBytes, maxBytes);
|
|
425
|
-
}
|
|
426
|
-
}
|
|
427
|
-
/** Backs `c.boundedBody`: bounded byte read that throws a flat 413/400 `Response` (caught by
|
|
428
|
-
* `runLifecycle` as control flow, like `throw redirect(...)`), so a handler can't ignore the cap.
|
|
429
|
-
* The byte-cap itself lives in `./body.ts` (shared with the schema path and `verifyWebhook`). */
|
|
430
|
-
async function readBoundedBodyOrThrow(req, maxBodyBytes, maxBytes) {
|
|
431
|
-
const r = await readBoundedBytes(req, maxBytes ?? maxBodyBytes);
|
|
432
|
-
if (r.ok)
|
|
433
|
-
return r.bytes;
|
|
434
|
-
throw r.status === 413
|
|
435
|
-
? jsonError(413, "payload_too_large")
|
|
436
|
-
: jsonError(400, "invalid_content_length");
|
|
437
|
-
}
|
|
438
|
-
/** Backs `c.boundedJson`: `readBoundedBodyOrThrow` + `JSON.parse`, throwing a flat 400 on bad JSON. */
|
|
439
|
-
async function readBoundedJsonBodyOrThrow(req, maxBodyBytes, maxBytes) {
|
|
440
|
-
const parsed = await readBoundedJsonSource(req, maxBytes ?? maxBodyBytes);
|
|
441
|
-
if (parsed instanceof Response)
|
|
442
|
-
throw parsed;
|
|
443
|
-
return parsed;
|
|
444
|
-
}
|
|
445
|
-
/**
|
|
446
|
-
* Read a JSON body with the same byte cap used by schema validation and `c.boundedJson`.
|
|
447
|
-
* Non-chunked, framed requests with an in-cap `Content-Length` use the runtime-native `req.json()`;
|
|
448
|
-
* chunked or length-less requests fall back to the streaming byte-cap guard.
|
|
449
|
-
*/
|
|
450
|
-
async function readBoundedJsonSource(req, maxBytes) {
|
|
451
|
-
const declared = headerOf(req, "content-length");
|
|
452
|
-
if (declared !== null) {
|
|
453
|
-
// A present Content-Length must be a non-negative integer (HTTP grammar: `1*DIGIT`). A
|
|
454
|
-
// non-numeric / negative / fractional / exponential value (`Number()` would happily accept
|
|
455
|
-
// "abc"→NaN, "-5", "1.5", "1e3", "0x10") is malformed → 400, rather than silently falling
|
|
456
|
-
// through to the streaming guard — which is an UPPER-bound cap only, so a lying SMALLER length
|
|
457
|
-
// would otherwise be read in full. Real HTTP servers only hand us a valid framed length; this
|
|
458
|
-
// hardens hand-built Requests (tests, the in-process client) and crafted input.
|
|
459
|
-
const length = parseContentLength(declared);
|
|
460
|
-
if (length === undefined)
|
|
461
|
-
return jsonError(400, "invalid_content_length");
|
|
462
|
-
if (length > maxBytes)
|
|
463
|
-
return jsonError(413, "payload_too_large");
|
|
464
|
-
const chunked = headerOf(req, "transfer-encoding") !== null;
|
|
465
|
-
if (!chunked) {
|
|
466
|
-
try {
|
|
467
|
-
return await req.json();
|
|
468
|
-
}
|
|
469
|
-
catch {
|
|
470
|
-
return jsonError(400, "invalid_json");
|
|
471
|
-
}
|
|
472
|
-
}
|
|
473
|
-
}
|
|
474
|
-
const body = req.body;
|
|
475
|
-
if (body === null)
|
|
476
|
-
return jsonError(400, "invalid_json");
|
|
477
|
-
const drained = await drainCapped(body, maxBytes);
|
|
478
|
-
if (!drained.ok)
|
|
479
|
-
return jsonError(413, "payload_too_large");
|
|
480
|
-
try {
|
|
481
|
-
return JSON.parse(TEXT_DECODER.decode(drained.bytes));
|
|
482
|
-
}
|
|
483
|
-
catch {
|
|
484
|
-
return jsonError(400, "invalid_json");
|
|
485
|
-
}
|
|
486
|
-
}
|
|
487
|
-
/** A fixed past instant for cookie deletion (`Expires`). A literal epoch — deterministic, unlike an
|
|
488
|
-
* argless `new Date()`. */
|
|
489
|
-
const EPOCH = new Date(0);
|
|
490
|
-
/** Build the response headers init. The common path (no `c.set`) returns `undefined` so `Response`
|
|
491
|
-
* gets no `headers` at all. Cookies force a `Headers` object — multiple `Set-Cookie`s can't live in a
|
|
492
|
-
* `Record<string,string>` (the 2nd would overwrite the 1st), so they're `append`ed individually. */
|
|
493
|
-
function headersInit(set) {
|
|
494
|
-
const cookies = set._cookies;
|
|
495
|
-
if (cookies === undefined || cookies.length === 0)
|
|
496
|
-
return set._headers;
|
|
497
|
-
const headers = new Headers(set._headers);
|
|
498
|
-
for (const cookie of cookies)
|
|
499
|
-
headers.append("set-cookie", cookie);
|
|
500
|
-
return headers;
|
|
501
|
-
}
|
|
502
|
-
// Keep the fast JSON respond path byte-identical to `Response.json` without probing it at module
|
|
503
|
-
// scope: workerd forbids `Response.json()` during startup. A shared `Headers` is safe to reuse
|
|
504
|
-
// across responses: the Response constructor copies `init.headers` into its own list.
|
|
505
|
-
const JSON_CT_HEADERS = new Headers({
|
|
506
|
-
"content-type": "application/json;charset=utf-8",
|
|
507
|
-
});
|
|
508
|
-
const JSON_INIT_200 = { status: 200, headers: JSON_CT_HEADERS };
|
|
509
|
-
/** Fused-lane respond when `c.set` is untouched. Bun 1.3's native `Response.json` now beats the
|
|
510
|
-
* older hand-inlined stringify + Response construction on this lane while preserving the exact
|
|
511
|
-
* body/content-type contract; keep the generic fallback for non-JSON values. */
|
|
512
|
-
function fusedRespondNoSet(result) {
|
|
513
|
-
if (result !== undefined &&
|
|
514
|
-
!(result instanceof Response) &&
|
|
515
|
-
typeof result === "object" &&
|
|
516
|
-
result !== null &&
|
|
517
|
-
!isResponseResult(result)) {
|
|
518
|
-
return Response.json(result);
|
|
519
|
-
}
|
|
520
|
-
return toResponse(result, EMPTY_RESPONSE_CONTROLS);
|
|
521
|
-
}
|
|
522
|
-
/** Fused-lane respond with a context: read `c.set` once; untouched (the common case) → the fast
|
|
523
|
-
* JSON respond; touched → the generic `toResponse` with those controls (statuses, headers, cookies). */
|
|
524
|
-
function fusedRespond(result, ctx) {
|
|
525
|
-
const set = ctx[CONTEXT_SET]();
|
|
526
|
-
if (set === undefined)
|
|
527
|
-
return fusedRespondNoSet(result);
|
|
528
|
-
return toResponse(result, set);
|
|
529
|
-
}
|
|
530
|
-
function toResponse(result, set) {
|
|
531
|
-
if (isResponseResult(result)) {
|
|
532
|
-
return appendCookiesToResponse(result.toResponse(), set);
|
|
533
|
-
}
|
|
534
|
-
if (result instanceof Response) {
|
|
535
|
-
return appendCookiesToResponse(result, set);
|
|
536
|
-
}
|
|
537
|
-
const headers = headersInit(set);
|
|
538
|
-
const status = set.status ?? (result === undefined ? 204 : 200);
|
|
539
|
-
if (headers === undefined && result !== undefined) {
|
|
540
|
-
// Fast respond (profiled ≈ −50 ns/req on every plain-JSON return): `JSON.stringify` + a
|
|
541
|
-
// prebuilt init beats `Response.json`'s internal init handling. Output is byte-identical —
|
|
542
|
-
// same body bytes, same probed content-type. `undefined` from stringify (a function/symbol
|
|
543
|
-
// result) delegates to Response.json so its TypeError contract stays the single source.
|
|
544
|
-
const body = JSON.stringify(result);
|
|
545
|
-
if (body !== undefined) {
|
|
546
|
-
return new Response(body, status === 200 ? JSON_INIT_200 : { status, headers: JSON_CT_HEADERS });
|
|
547
|
-
}
|
|
548
|
-
}
|
|
549
|
-
const init = headers === undefined ? { status } : { status, headers };
|
|
550
|
-
return result === undefined ? new Response(null, init) : Response.json(result, init);
|
|
551
|
-
}
|
|
552
|
-
function appendCookiesToResponse(response, set) {
|
|
553
|
-
// A handler may queue cookies (`c.set.cookie` — e.g. a session cookie) AND return its own Response
|
|
554
|
-
// (e.g. `redirect("/")` after login). Cookies accumulate additively, so append them to the
|
|
555
|
-
// returned Response — otherwise the canonical set-session-then-redirect pattern would silently drop
|
|
556
|
-
// the cookie. (Other `c.set` fields stay the returned Response's own concern.)
|
|
557
|
-
const cookies = set._cookies;
|
|
558
|
-
if (cookies !== undefined && cookies.length > 0) {
|
|
559
|
-
for (const cookie of cookies)
|
|
560
|
-
response.headers.append("set-cookie", cookie);
|
|
561
|
-
}
|
|
562
|
-
return response;
|
|
563
|
-
}
|
|
564
|
-
/**
|
|
565
|
-
* `finalize` for the node-direct path — mirror of {@link toResponse} that skips the `Response` build:
|
|
566
|
-
* a plain value becomes pre-stringified JSON primitives (the adapter `JSON.stringify`s once, here, not
|
|
567
|
-
* via `Response.json` + a body drain); a handler-returned `Response` is wrapped as-is, with queued
|
|
568
|
-
* cookies appended exactly as `toResponse` does (so the set-cookie-then-`redirect()` pattern still
|
|
569
|
-
* works on Node).
|
|
570
|
-
*/
|
|
571
|
-
function toNodeOutcome(result, set) {
|
|
572
|
-
if (isResponseResult(result)) {
|
|
573
|
-
const body = result.toNodeBody?.();
|
|
574
|
-
if (body !== undefined) {
|
|
575
|
-
return {
|
|
576
|
-
kind: "body",
|
|
577
|
-
status: body.status,
|
|
578
|
-
headers: appendCookiesToNodeHeaders(body.headers, set._cookies),
|
|
579
|
-
body: body.body,
|
|
580
|
-
};
|
|
581
|
-
}
|
|
582
|
-
return nodeOutcomeFromResponse(appendCookiesToResponse(result.toResponse(), set));
|
|
583
|
-
}
|
|
584
|
-
if (result instanceof Response) {
|
|
585
|
-
return nodeOutcomeFromResponse(appendCookiesToResponse(result, set));
|
|
586
|
-
}
|
|
587
|
-
const status = set.status ?? (result === undefined ? 204 : 200);
|
|
588
|
-
return {
|
|
589
|
-
kind: "json",
|
|
590
|
-
status,
|
|
591
|
-
headers: set._headers,
|
|
592
|
-
cookies: set._cookies,
|
|
593
|
-
body: result === undefined ? null : JSON.stringify(result),
|
|
594
|
-
};
|
|
595
|
-
}
|
|
596
145
|
// Stable module-level finalizers so `fetch`/`resolveNode` allocate no per-request closures.
|
|
597
146
|
const IDENTITY_RESPONSE = (response) => response;
|
|
598
147
|
const RESPONSE_TIMEOUT = () => jsonError(503, "request_timeout");
|
|
599
|
-
const NODE_RESPONSE_BODY = Symbol.for("nifra.response.body");
|
|
600
|
-
const NODE_FROM_RESPONSE = (response) => nodeOutcomeFromResponse(response);
|
|
601
|
-
const NODE_TIMEOUT = () => ({
|
|
602
|
-
kind: "response",
|
|
603
|
-
response: jsonError(503, "request_timeout"),
|
|
604
|
-
});
|
|
605
|
-
function nodeOutcomeFromResponse(response) {
|
|
606
|
-
const body = nodeResponseBody(response);
|
|
607
|
-
return body === undefined
|
|
608
|
-
? { kind: "response", response }
|
|
609
|
-
: { kind: "body", status: response.status, headers: responseHeadersForNode(response), body };
|
|
610
|
-
}
|
|
611
|
-
function nodeResponseBody(response) {
|
|
612
|
-
if (response.bodyUsed)
|
|
613
|
-
return undefined;
|
|
614
|
-
const body = response[NODE_RESPONSE_BODY];
|
|
615
|
-
return typeof body === "string" || body instanceof Uint8Array ? body : undefined;
|
|
616
|
-
}
|
|
617
|
-
function responseHeadersForNode(response) {
|
|
618
|
-
let headers;
|
|
619
|
-
response.headers.forEach((value, key) => {
|
|
620
|
-
headers ??= {};
|
|
621
|
-
headers[key] = value;
|
|
622
|
-
});
|
|
623
|
-
const setCookies = response.headers.getSetCookie?.();
|
|
624
|
-
if (setCookies !== undefined && setCookies.length > 0) {
|
|
625
|
-
headers ??= {};
|
|
626
|
-
headers["set-cookie"] = setCookies;
|
|
627
|
-
}
|
|
628
|
-
return headers;
|
|
629
|
-
}
|
|
630
|
-
function appendCookiesToNodeHeaders(headers, cookies) {
|
|
631
|
-
if (cookies === undefined || cookies.length === 0)
|
|
632
|
-
return headers;
|
|
633
|
-
const out = headers === undefined ? {} : { ...headers };
|
|
634
|
-
const existing = out["set-cookie"];
|
|
635
|
-
const setCookies = existing === undefined ? [] : typeof existing === "string" ? [existing] : [...existing];
|
|
636
|
-
out["set-cookie"] = [...setCookies, ...cookies];
|
|
637
|
-
return out;
|
|
638
|
-
}
|
|
639
148
|
/**
|
|
640
149
|
* The inline server. Routes are chainable and fully type-inferred. `derive`/
|
|
641
150
|
* `decorate` extend the handler context (`Ctx`) for routes defined *after* them,
|
|
@@ -645,7 +154,7 @@ function appendCookiesToNodeHeaders(headers, cookies) {
|
|
|
645
154
|
* .get("/me", (c) => c.user) // c.user + c.db are typed
|
|
646
155
|
*/
|
|
647
156
|
export class Server {
|
|
648
|
-
|
|
157
|
+
catalog;
|
|
649
158
|
/** WebSocket routes, matched separately at upgrade time (a GET + `Upgrade: websocket`). */
|
|
650
159
|
wsRouter;
|
|
651
160
|
wsRouteCount;
|
|
@@ -653,10 +162,11 @@ export class Server {
|
|
|
653
162
|
* Created by the first `app.ws()` via the `@nifrajs/core/ws` runtime — `undefined` until then, so a
|
|
654
163
|
* no-WebSocket app never constructs (or bundles) it. */
|
|
655
164
|
topics;
|
|
656
|
-
routeList;
|
|
657
165
|
maxBodyBytes;
|
|
658
166
|
wsMaxPayloadBytes;
|
|
659
167
|
requestTimeoutMs;
|
|
168
|
+
/** Opt-in caller-IP trust declaration; `undefined` = socket peer only, no forwarded header believed. */
|
|
169
|
+
clientIpTrust;
|
|
660
170
|
acceptInboundDeadlines;
|
|
661
171
|
maxInboundDeadlineMs;
|
|
662
172
|
deadlineAdmissionOptions;
|
|
@@ -664,15 +174,25 @@ export class Server {
|
|
|
664
174
|
/** Capacity-admission gate; `undefined` = off (the request path pays nothing). */
|
|
665
175
|
capacityGate;
|
|
666
176
|
onCapabilityUse;
|
|
667
|
-
/**
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
177
|
+
/** The installed effect-ledger runtime (owns the sink + per-route resolution + settle), or
|
|
178
|
+
* `undefined` when the effect-ledger plugin is not installed. */
|
|
179
|
+
effectLedgerRuntime;
|
|
180
|
+
/** The installed idempotency runtime (owns the app-wide default store + the dedupe lane), or
|
|
181
|
+
* `undefined` when the idempotency plugin is not installed. */
|
|
182
|
+
idempotencyRuntime;
|
|
183
|
+
/** Installed opt-in runtime for `.tool()`/`.resource()`/`.prompt()`; `undefined` until `.use(mcp())`. */
|
|
184
|
+
mcpRuntime;
|
|
185
|
+
/** Installed Node-direct renderer for direct `resolveNode()` callers; `undefined` until `.use(nodeDirect())`. */
|
|
186
|
+
nodeOutcomeRuntime;
|
|
187
|
+
/** Installed streaming runtime for `.sse()` routes; `undefined` until `.use(streaming())`. */
|
|
188
|
+
sseRuntime;
|
|
189
|
+
/** Installed WebSocket runtime for `.ws()` routes; `undefined` until `.use(websocket())`. */
|
|
190
|
+
wsRuntime;
|
|
672
191
|
logger;
|
|
673
192
|
/** App-wide validation-error fallback; a route's own `schema.onValidationError` takes precedence. */
|
|
674
193
|
defaultOnValidationError;
|
|
675
194
|
bunServer;
|
|
195
|
+
sealed;
|
|
676
196
|
derives;
|
|
677
197
|
decorations;
|
|
678
198
|
beforeHandleHooks;
|
|
@@ -692,22 +212,15 @@ export class Server {
|
|
|
692
212
|
/** App-declared MCP resources / prompts (via {@link resource} / {@link prompt}), read by `nifra mcp`. */
|
|
693
213
|
mcpResourceList;
|
|
694
214
|
mcpPromptList;
|
|
695
|
-
/**
|
|
696
|
-
* Every registered route's raw router entry + descriptor, in order — the replay source for
|
|
697
|
-
* {@link merge}. The entry already carries the chains captured at registration (derives,
|
|
698
|
-
* decorations, before/after/around/onError), so a merged route behaves exactly as it did on
|
|
699
|
-
* the server that defined it.
|
|
700
|
-
*/
|
|
701
|
-
rawEntries;
|
|
702
215
|
constructor(options = {}) {
|
|
703
|
-
this.
|
|
216
|
+
this.catalog = new RouteCatalog();
|
|
704
217
|
this.wsRouter = new Router();
|
|
705
218
|
this.wsRouteCount = 0;
|
|
706
219
|
this.topics = undefined;
|
|
707
|
-
this.routeList = [];
|
|
708
220
|
this.maxBodyBytes = options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES;
|
|
709
221
|
this.wsMaxPayloadBytes = options.wsMaxPayloadBytes ?? this.maxBodyBytes;
|
|
710
222
|
this.requestTimeoutMs = options.requestTimeoutMs ?? 0;
|
|
223
|
+
this.clientIpTrust = options.clientIp;
|
|
711
224
|
this.acceptInboundDeadlines = options.acceptInboundDeadlines ?? false;
|
|
712
225
|
this.maxInboundDeadlineMs = options.maxInboundDeadlineMs ?? 30_000;
|
|
713
226
|
this.deadlineAdmissionOptions = Object.freeze({
|
|
@@ -723,29 +236,16 @@ export class Server {
|
|
|
723
236
|
this.gracefulSignals = options.gracefulSignals ?? false;
|
|
724
237
|
this.capacityGate = options.admission;
|
|
725
238
|
this.onCapabilityUse = options.onCapabilityUse;
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
if (!Number.isSafeInteger(maxEntries) || maxEntries < 1) {
|
|
733
|
-
throw new RangeError("effectLedger.maxEntries must be a positive safe integer");
|
|
734
|
-
}
|
|
735
|
-
this.effectLedger = Object.freeze({
|
|
736
|
-
sink: ledger.sink,
|
|
737
|
-
maxEntries,
|
|
738
|
-
chain: ledger.chain ?? false,
|
|
739
|
-
});
|
|
740
|
-
}
|
|
741
|
-
else {
|
|
742
|
-
this.effectLedger = undefined;
|
|
743
|
-
}
|
|
744
|
-
this.configuredIdempotencyStore = options.idempotencyStore;
|
|
745
|
-
this.lazyIdempotencyStore = undefined;
|
|
239
|
+
// The effect-ledger runtime is installed by `.use(effectLedger())`; a bare app never imports it, so
|
|
240
|
+
// the ledger machinery tree-shakes out. Capability-declaring routes simply carry no ledger without it.
|
|
241
|
+
this.effectLedgerRuntime = undefined;
|
|
242
|
+
// The idempotency runtime is installed by `.use(idempotency())`; a bare app never imports it, so the
|
|
243
|
+
// dedupe machinery tree-shakes out. A route that declares idempotency without it is a build error.
|
|
244
|
+
this.idempotencyRuntime = undefined;
|
|
746
245
|
this.logger = options.logger ?? jsonLogger();
|
|
747
246
|
this.defaultOnValidationError = options.onValidationError;
|
|
748
247
|
this.bunServer = undefined;
|
|
248
|
+
this.sealed = false;
|
|
749
249
|
this.derives = [];
|
|
750
250
|
this.decorations = {};
|
|
751
251
|
this.beforeHandleHooks = [];
|
|
@@ -761,15 +261,21 @@ export class Server {
|
|
|
761
261
|
this.globalAssurance = [];
|
|
762
262
|
this.mcpResourceList = [];
|
|
763
263
|
this.mcpPromptList = [];
|
|
764
|
-
|
|
264
|
+
}
|
|
265
|
+
assertConfigurable(operation) {
|
|
266
|
+
if (this.sealed) {
|
|
267
|
+
throw new FrameworkError("SERVER_SEALED", `server configuration is sealed after listen(); call ${operation} before listen()`);
|
|
268
|
+
}
|
|
765
269
|
}
|
|
766
270
|
/** Add a per-request, computed context extension for subsequent routes. */
|
|
767
271
|
derive(fn) {
|
|
272
|
+
this.assertConfigurable("derive()");
|
|
768
273
|
this.derives.push(fn);
|
|
769
274
|
return this;
|
|
770
275
|
}
|
|
771
276
|
/** Add a static context value for subsequent routes. */
|
|
772
277
|
decorate(key, value) {
|
|
278
|
+
this.assertConfigurable("decorate()");
|
|
773
279
|
this.decorations[key] = value;
|
|
774
280
|
return this;
|
|
775
281
|
}
|
|
@@ -778,11 +284,13 @@ export class Server {
|
|
|
778
284
|
* `Request` to continue routing with a rewritten method/URL/headers. Global.
|
|
779
285
|
*/
|
|
780
286
|
onRequest(fn) {
|
|
287
|
+
this.assertConfigurable("onRequest()");
|
|
781
288
|
this.onRequestHooks.push(fn);
|
|
782
289
|
return this;
|
|
783
290
|
}
|
|
784
291
|
/** Run after validation, before the handler; a non-`undefined` return short-circuits. Order-scoped. */
|
|
785
292
|
beforeHandle(fn) {
|
|
293
|
+
this.assertConfigurable("beforeHandle()");
|
|
786
294
|
this.beforeHandleHooks.push(fn);
|
|
787
295
|
return this;
|
|
788
296
|
}
|
|
@@ -792,30 +300,36 @@ export class Server {
|
|
|
792
300
|
* a Web `Response`. The first registered wrapper is outermost.
|
|
793
301
|
*/
|
|
794
302
|
around(fn) {
|
|
303
|
+
this.assertConfigurable("around()");
|
|
795
304
|
this.aroundHooks.push(fn);
|
|
796
305
|
return this;
|
|
797
306
|
}
|
|
798
307
|
/** Transform the handler's result before it is serialized. Order-scoped. */
|
|
799
308
|
afterHandle(fn) {
|
|
309
|
+
this.assertConfigurable("afterHandle()");
|
|
800
310
|
this.afterHandleHooks.push(fn);
|
|
801
311
|
return this;
|
|
802
312
|
}
|
|
803
313
|
/** Handle a thrown error; a non-`undefined` return becomes the response (else the default 500). Order-scoped. */
|
|
804
314
|
onError(fn) {
|
|
315
|
+
this.assertConfigurable("onError()");
|
|
805
316
|
this.onErrorHooks.push(fn);
|
|
806
317
|
return this;
|
|
807
318
|
}
|
|
808
319
|
/** Transform every outgoing response — success, error, 404, 405, short-circuit. Global. */
|
|
809
320
|
onResponse(fn) {
|
|
321
|
+
this.assertConfigurable("onResponse()");
|
|
810
322
|
this.onResponseHooks.push(fn);
|
|
811
323
|
return this;
|
|
812
324
|
}
|
|
813
325
|
/** Observe the terminal response after all transformations. Observers are ordered and fail-open. */
|
|
814
326
|
onResponseFinalized(fn) {
|
|
327
|
+
this.assertConfigurable("onResponseFinalized()");
|
|
815
328
|
this.onResponseFinalizedHooks.push(fn);
|
|
816
329
|
return this;
|
|
817
330
|
}
|
|
818
331
|
use(arg) {
|
|
332
|
+
this.assertConfigurable("use()");
|
|
819
333
|
if (typeof arg === "function") {
|
|
820
334
|
const name = arg.pluginName;
|
|
821
335
|
if (name !== undefined) {
|
|
@@ -878,7 +392,8 @@ export class Server {
|
|
|
878
392
|
* JSON-serialized into the SSE `data:` field. The typed client sees the marker and grows a
|
|
879
393
|
* `.subscribe(onEvent)` for the route with the same payload type — end-to-end typed streaming.
|
|
880
394
|
*
|
|
881
|
-
*
|
|
395
|
+
* import { streaming } from "@nifrajs/core/sse" // .use(streaming()) enables .sse()
|
|
396
|
+
* const app = server().use(streaming()).sse("/feed", { sse: t.object({ id: t.integer(), title: t.string() }) },
|
|
882
397
|
* async (c, stream) => {
|
|
883
398
|
* stream.send({ id: 1, title: "hello" }) // typed
|
|
884
399
|
* await waitForDisconnect(stream.signal)
|
|
@@ -892,7 +407,7 @@ export class Server {
|
|
|
892
407
|
* (`stream.signal`). Query/body schemas validate exactly as on any other route.
|
|
893
408
|
*/
|
|
894
409
|
sse(path, schema, run, init) {
|
|
895
|
-
const handler = (context) =>
|
|
410
|
+
const handler = (context) => requireSseRuntime(this.sseRuntime).response(context, (stream) => run(context, stream), init);
|
|
896
411
|
return this.route("GET", path, schema, handler);
|
|
897
412
|
}
|
|
898
413
|
post(path, schemaOrHandler, handler) {
|
|
@@ -908,24 +423,14 @@ export class Server {
|
|
|
908
423
|
return this.route("DELETE", path, schemaOrHandler, handler);
|
|
909
424
|
}
|
|
910
425
|
tool(name, config, handler) {
|
|
911
|
-
const
|
|
912
|
-
|
|
913
|
-
? { body: config.input, response: config.output }
|
|
914
|
-
: { body: config.input };
|
|
915
|
-
// register's handler is the erased `(context: never) => unknown`; the tool's typed input/ctx come from
|
|
916
|
-
// the overload above, so bridge with a single cast rather than `any`.
|
|
917
|
-
const run = (c) => handler(c.body, c);
|
|
918
|
-
this.register("POST", path, routeSchema, run);
|
|
426
|
+
const plan = requireMcpRuntime(this.mcpRuntime).tool(name, config, handler);
|
|
427
|
+
this.register("POST", plan.path, plan.schema, plan.run);
|
|
919
428
|
// Tag the just-registered descriptor as an MCP tool. `tool` is readonly on RouteDescriptor (an
|
|
920
429
|
// introspection field), so write it through a narrow mutable view — not `any`.
|
|
921
|
-
const lastRoute = this.
|
|
430
|
+
const lastRoute = this.catalog.lastDescriptor();
|
|
922
431
|
if (lastRoute) {
|
|
923
432
|
;
|
|
924
|
-
lastRoute.tool =
|
|
925
|
-
name,
|
|
926
|
-
description: config.description,
|
|
927
|
-
...(config.annotations !== undefined ? { annotations: config.annotations } : {}),
|
|
928
|
-
};
|
|
433
|
+
lastRoute.tool = plan.descriptor;
|
|
929
434
|
}
|
|
930
435
|
return this;
|
|
931
436
|
}
|
|
@@ -936,13 +441,8 @@ export class Server {
|
|
|
936
441
|
* for the resource half of MCP.
|
|
937
442
|
*/
|
|
938
443
|
resource(uri, config, read) {
|
|
939
|
-
this.
|
|
940
|
-
|
|
941
|
-
name: config.name,
|
|
942
|
-
...(config.description !== undefined ? { description: config.description } : {}),
|
|
943
|
-
...(config.mimeType !== undefined ? { mimeType: config.mimeType } : {}),
|
|
944
|
-
read,
|
|
945
|
-
});
|
|
444
|
+
this.assertConfigurable("resource()");
|
|
445
|
+
this.mcpResourceList.push(requireMcpRuntime(this.mcpRuntime).resource(uri, config, read));
|
|
946
446
|
return this;
|
|
947
447
|
}
|
|
948
448
|
/**
|
|
@@ -950,12 +450,8 @@ export class Server {
|
|
|
950
450
|
* `handler` receives the caller's arguments and returns the rendered messages.
|
|
951
451
|
*/
|
|
952
452
|
prompt(name, config, handler) {
|
|
953
|
-
this.
|
|
954
|
-
|
|
955
|
-
description: config.description,
|
|
956
|
-
...(config.arguments !== undefined ? { arguments: config.arguments } : {}),
|
|
957
|
-
handler,
|
|
958
|
-
});
|
|
453
|
+
this.assertConfigurable("prompt()");
|
|
454
|
+
this.mcpPromptList.push(requireMcpRuntime(this.mcpRuntime).prompt(name, config, handler));
|
|
959
455
|
return this;
|
|
960
456
|
}
|
|
961
457
|
/** The MCP resources declared via {@link resource} — enumerated by `nifra mcp`. */
|
|
@@ -973,12 +469,19 @@ export class Server {
|
|
|
973
469
|
* adapter (`listen()`, `@nifrajs/node`, `@nifrajs/deno`, `toFetchHandler`) — not by bare `app.fetch`, which
|
|
974
470
|
* has no socket (a WS path through `app.fetch` is a normal HTTP response).
|
|
975
471
|
*
|
|
472
|
+
* The route also enters the type-level registry (under the pseudo-method `"WS"`), so the typed
|
|
473
|
+
* client grows a `.ws()` handle for it: `messageSchema` types what the client may `send`,
|
|
474
|
+
* `sendSchema` types the frames it receives. Passing explicit type arguments (`ws<MyData>(…)`)
|
|
475
|
+
* defeats path-literal inference and skips the registry entry - the route still serves, it is just
|
|
476
|
+
* invisible to `client<App>`; prefer typing `data` via `upgrade()`'s return.
|
|
477
|
+
*
|
|
976
478
|
* app.ws("/chat", { open: (ws) => ws.send("hi"), message: (ws, data) => ws.send(data) })
|
|
977
479
|
*/
|
|
978
480
|
ws(path, handler) {
|
|
481
|
+
this.assertConfigurable("ws()");
|
|
979
482
|
// Boot-time guard: the WS runtime is a subpath (`@nifrajs/core/ws`) so no-WebSocket apps don't
|
|
980
483
|
// bundle it. Registration is the loud, early failure point — never the first connection.
|
|
981
|
-
const runtime = requireWsRuntime();
|
|
484
|
+
const runtime = requireWsRuntime(this.wsRuntime);
|
|
982
485
|
this.topics ??= runtime.createTopics();
|
|
983
486
|
// A `messageSchema` wraps `message` with validation once, here — every adapter then dispatches
|
|
984
487
|
// already-validated, typed messages (Bun/Deno/Node/Workers) with no per-adapter code.
|
|
@@ -1019,6 +522,18 @@ export class Server {
|
|
|
1019
522
|
* is the "compiled", order-scoped per-route chain.
|
|
1020
523
|
*/
|
|
1021
524
|
register(method, path, schema, handler) {
|
|
525
|
+
this.assertConfigurable("route registration");
|
|
526
|
+
this.catalog.add(this.prepareRoute(method, path, schema, handler));
|
|
527
|
+
}
|
|
528
|
+
/** Register a contract/group route batch atomically. Every route captures the same current chain it
|
|
529
|
+
* would capture through {@link register}; no route becomes visible unless the full batch validates. */
|
|
530
|
+
registerBatch(routes) {
|
|
531
|
+
this.assertConfigurable("route registration");
|
|
532
|
+
const staged = routes.map(({ method, path, schema, handler }) => this.prepareRoute(method, path, schema, handler));
|
|
533
|
+
this.catalog.addBatch(staged);
|
|
534
|
+
}
|
|
535
|
+
prepareRoute(method, path, schema, handler) {
|
|
536
|
+
const pattern = compileRoutePattern(path);
|
|
1022
537
|
const capabilities = normalizeRouteCapabilities(schema?.capabilities);
|
|
1023
538
|
const handlerAssurance = assuranceDeclarationsOf(handler);
|
|
1024
539
|
const invalidHandlerScope = handlerAssurance.find((declaration) => declaration.scope !== "plugin");
|
|
@@ -1033,15 +548,19 @@ export class Server {
|
|
|
1033
548
|
const hasDecorations = Reflect.ownKeys(routeDecorations).length > 0;
|
|
1034
549
|
// An idempotency route runs a dedupe lane that must buffer the body and capture the response, so it
|
|
1035
550
|
// never takes the fused/native fast path — force it onto the portable matched lane (which routes
|
|
1036
|
-
// through `fetchMatched`, where the dedupe wrapper lives).
|
|
1037
|
-
|
|
1038
|
-
//
|
|
551
|
+
// through `fetchMatched`, where the dedupe wrapper lives). Fail closed: a route may not declare
|
|
552
|
+
// idempotency unless the idempotency runtime is installed, so the safety gate can never be silently
|
|
553
|
+
// dropped by a missing plugin.
|
|
554
|
+
if (schema?.idempotency !== undefined && this.idempotencyRuntime === undefined) {
|
|
555
|
+
throw new RouteConfigError("INVALID_IDEMPOTENCY", "route declares idempotency but the idempotency plugin is not installed; add .use(idempotency())");
|
|
556
|
+
}
|
|
557
|
+
const idempotent = this.idempotencyRuntime?.resolve(schema, authenticated, this.maxBodyBytes);
|
|
558
|
+
// A ledgered route (capabilities declared + `.use(effectLedger())`) needs a per-request
|
|
1039
559
|
// context to carry the ledger and a settle step to seal + sink it, so it too leaves the
|
|
1040
560
|
// fused/contextless fast path. Resolved per route, at registration — like the capability guard.
|
|
1041
|
-
const ledgered =
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
const bare = schema?.body === undefined &&
|
|
561
|
+
const ledgered = this.effectLedgerRuntime?.resolve(capabilities, method, path);
|
|
562
|
+
const bare = schema?.params === undefined &&
|
|
563
|
+
schema?.body === undefined &&
|
|
1045
564
|
schema?.query === undefined &&
|
|
1046
565
|
idempotent === undefined &&
|
|
1047
566
|
ledgered === undefined &&
|
|
@@ -1052,6 +571,27 @@ export class Server {
|
|
|
1052
571
|
const fusedWeb = bare && this.aroundHooks.length === 0
|
|
1053
572
|
? this.buildFusedWeb(handler, hasDecorations ? routeDecorations : undefined, isContextlessNoArgArrow(handler))
|
|
1054
573
|
: undefined;
|
|
574
|
+
const contextless = bare && this.aroundHooks.length === 0 && isContextlessNoArgArrow(handler);
|
|
575
|
+
const lane = bare
|
|
576
|
+
? "bare"
|
|
577
|
+
: schema?.body !== undefined &&
|
|
578
|
+
schema.query === undefined &&
|
|
579
|
+
schema.params === undefined &&
|
|
580
|
+
this.derives.length === 0 &&
|
|
581
|
+
this.beforeHandleHooks.length === 0 &&
|
|
582
|
+
this.afterHandleHooks.length === 0 &&
|
|
583
|
+
this.onErrorHooks.length === 0
|
|
584
|
+
? "body"
|
|
585
|
+
: schema?.body === undefined &&
|
|
586
|
+
schema?.query !== undefined &&
|
|
587
|
+
schema.params === undefined &&
|
|
588
|
+
this.derives.length === 0 &&
|
|
589
|
+
this.beforeHandleHooks.length === 0 &&
|
|
590
|
+
this.afterHandleHooks.length === 0 &&
|
|
591
|
+
this.onErrorHooks.length === 0
|
|
592
|
+
? "query"
|
|
593
|
+
: "lifecycle";
|
|
594
|
+
const execution = this.compileExecutionPlan(lane, contextless, this.aroundHooks.length > 0, ledgered !== undefined, fusedWeb);
|
|
1055
595
|
const entry = {
|
|
1056
596
|
// (context: never) => unknown -> InternalHandler: the framework invokes it
|
|
1057
597
|
// with the concrete RawContext the typed handler expects, so this is sound.
|
|
@@ -1059,7 +599,6 @@ export class Server {
|
|
|
1059
599
|
schema,
|
|
1060
600
|
idempotent,
|
|
1061
601
|
ledgered,
|
|
1062
|
-
fusedWeb,
|
|
1063
602
|
derives: [...this.derives],
|
|
1064
603
|
decorations: routeDecorations,
|
|
1065
604
|
hasDecorations,
|
|
@@ -1067,34 +606,25 @@ export class Server {
|
|
|
1067
606
|
afterHandle: [...this.afterHandleHooks],
|
|
1068
607
|
onError: [...this.onErrorHooks],
|
|
1069
608
|
around: [...this.aroundHooks],
|
|
1070
|
-
|
|
1071
|
-
// decorations and generic around wrappers are fine: around wraps the inner bare route in
|
|
1072
|
-
// `routeAndRun`, while the inner validation/derive/before/after/onError lifecycle remains empty.
|
|
1073
|
-
// Most GET/no-schema routes qualify.
|
|
1074
|
-
bare,
|
|
1075
|
-
contextlessBare: bare && this.aroundHooks.length === 0 && isContextlessNoArgArrow(handler),
|
|
1076
|
-
bodyOnly: schema?.body !== undefined &&
|
|
1077
|
-
schema.query === undefined &&
|
|
1078
|
-
this.derives.length === 0 &&
|
|
1079
|
-
this.beforeHandleHooks.length === 0 &&
|
|
1080
|
-
this.afterHandleHooks.length === 0 &&
|
|
1081
|
-
this.onErrorHooks.length === 0,
|
|
1082
|
-
queryOnly: schema?.body === undefined &&
|
|
1083
|
-
schema?.query !== undefined &&
|
|
1084
|
-
this.derives.length === 0 &&
|
|
1085
|
-
this.beforeHandleHooks.length === 0 &&
|
|
1086
|
-
this.afterHandleHooks.length === 0 &&
|
|
1087
|
-
this.onErrorHooks.length === 0,
|
|
609
|
+
execution,
|
|
1088
610
|
};
|
|
1089
|
-
this.router.add(method, path, entry);
|
|
1090
611
|
const descriptor = {
|
|
1091
612
|
method,
|
|
1092
613
|
path,
|
|
1093
614
|
schema,
|
|
1094
615
|
...(capabilities.length > 0 ? { capabilities } : {}),
|
|
616
|
+
...(schema?.family === true ? { family: true } : {}),
|
|
1095
617
|
};
|
|
1096
|
-
this.routeList.push(descriptor);
|
|
1097
618
|
const routeAssurance = [...this.activeAssurance, ...handlerAssurance];
|
|
619
|
+
// Inline `schema.assurance`: the route DECLARES its enforcement evidence adjacent to the handler, so an
|
|
620
|
+
// in-handler-guarded route satisfies a policy `require:` clause without a `withRouteAssurance` middleware
|
|
621
|
+
// rewrite. Each id becomes route-scoped `declared` evidence (invalid ids fail closed at registration).
|
|
622
|
+
for (const id of schema?.assurance ?? []) {
|
|
623
|
+
if (!validEvidenceId(id)) {
|
|
624
|
+
throw new Error(`route assurance: invalid evidence id ${JSON.stringify(id)} on ${method} ${path} (use lowercase dot/dash segments)`);
|
|
625
|
+
}
|
|
626
|
+
routeAssurance.push(Object.freeze({ id, source: "declared", scope: "plugin" }));
|
|
627
|
+
}
|
|
1098
628
|
if (schema?.body !== undefined) {
|
|
1099
629
|
routeAssurance.push(Object.freeze({
|
|
1100
630
|
id: NIFRA_ASSURANCE_IDS.BODY_BOUNDED,
|
|
@@ -1111,65 +641,88 @@ export class Server {
|
|
|
1111
641
|
scope: "plugin",
|
|
1112
642
|
}));
|
|
1113
643
|
}
|
|
1114
|
-
|
|
644
|
+
return {
|
|
1115
645
|
method,
|
|
1116
646
|
path,
|
|
647
|
+
pattern,
|
|
1117
648
|
entry,
|
|
1118
649
|
descriptor,
|
|
1119
650
|
assurance: Object.freeze(routeAssurance),
|
|
1120
|
-
}
|
|
1121
|
-
}
|
|
1122
|
-
/** Resolve a route's declared idempotency into a pinned store + defaults, or `undefined` when off. */
|
|
1123
|
-
resolveIdempotency(schema, authenticated) {
|
|
1124
|
-
const config = schema?.idempotency;
|
|
1125
|
-
if (config === undefined)
|
|
1126
|
-
return undefined;
|
|
1127
|
-
if (schema?.sse !== undefined) {
|
|
1128
|
-
throw new RouteConfigError("INVALID_IDEMPOTENCY", "streaming/SSE responses cannot be used on an idempotency route");
|
|
1129
|
-
}
|
|
1130
|
-
const store = config.store ?? this.defaultIdempotencyStore();
|
|
1131
|
-
if (config.scope === "durable" && store.durability !== "durable") {
|
|
1132
|
-
throw new RouteConfigError("INVALID_IDEMPOTENCY", 'durable idempotency requires a durable store (store.durability must be "durable")');
|
|
1133
|
-
}
|
|
1134
|
-
const ttlMs = config.ttlMs ?? DEFAULT_IDEMPOTENCY_TTL_MS;
|
|
1135
|
-
if (!Number.isSafeInteger(ttlMs) || ttlMs <= 0) {
|
|
1136
|
-
throw new RouteConfigError("INVALID_IDEMPOTENCY", "idempotency ttlMs must be a finite positive number");
|
|
1137
|
-
}
|
|
1138
|
-
const headerName = (config.headerName ?? DEFAULT_IDEMPOTENCY_HEADER).toLowerCase();
|
|
1139
|
-
try {
|
|
1140
|
-
new Headers().set(headerName, "probe");
|
|
1141
|
-
}
|
|
1142
|
-
catch {
|
|
1143
|
-
throw new RouteConfigError("INVALID_IDEMPOTENCY", `invalid idempotency header name ${JSON.stringify(headerName)}`);
|
|
1144
|
-
}
|
|
1145
|
-
const namespace = config.namespace;
|
|
1146
|
-
if (namespace === undefined) {
|
|
1147
|
-
throw new RouteConfigError("INVALID_IDEMPOTENCY", "idempotency namespace is required; use a principal resolver or an explicit shared scope");
|
|
1148
|
-
}
|
|
1149
|
-
if (typeof namespace === "string" && !validIdempotencyNamespace(namespace)) {
|
|
1150
|
-
throw new RouteConfigError("INVALID_IDEMPOTENCY", `invalid idempotency namespace ${JSON.stringify(namespace)}`);
|
|
1151
|
-
}
|
|
1152
|
-
if (authenticated && typeof namespace !== "function") {
|
|
1153
|
-
throw new RouteConfigError("INVALID_IDEMPOTENCY", "authenticated idempotency routes require a principal namespace resolver");
|
|
1154
|
-
}
|
|
1155
|
-
const maxResponseBytes = config.maxResponseBytes ?? this.maxBodyBytes;
|
|
1156
|
-
if (!Number.isSafeInteger(maxResponseBytes) || maxResponseBytes < 1) {
|
|
1157
|
-
throw new RouteConfigError("INVALID_IDEMPOTENCY", "idempotency maxResponseBytes must be a positive integer");
|
|
1158
|
-
}
|
|
1159
|
-
return Object.freeze({
|
|
1160
|
-
store,
|
|
1161
|
-
ttlMs,
|
|
1162
|
-
headerName,
|
|
1163
|
-
namespace,
|
|
1164
|
-
maxResponseBytes,
|
|
1165
|
-
});
|
|
651
|
+
};
|
|
1166
652
|
}
|
|
1167
|
-
/**
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
653
|
+
/** Collapse route-invariant lifecycle decisions into one runner at registration. The request path
|
|
654
|
+
* performs no eligibility ladder: it supplies request state to this already-selected plan. */
|
|
655
|
+
compileExecutionPlan(lane, contextless, hasAround, hasLedger, fusedWeb) {
|
|
656
|
+
if (contextless) {
|
|
657
|
+
const run = (runtime, entry, source, params, search, signal, budget, platform, finalize, wrapResponse) => runtime.runContextlessBare(entry, source, params, search, signal, budget, platform, finalize, wrapResponse);
|
|
658
|
+
return Object.freeze({ run, fusedWeb });
|
|
659
|
+
}
|
|
660
|
+
let inner;
|
|
661
|
+
switch (lane) {
|
|
662
|
+
case "bare":
|
|
663
|
+
inner = (runtime, entry, _source, ctx, finalize, wrapResponse) => runtime.runBare(entry, ctx, finalize, wrapResponse);
|
|
664
|
+
break;
|
|
665
|
+
case "body":
|
|
666
|
+
inner = (runtime, entry, source, ctx, finalize, wrapResponse) => runtime.runBodyOnly(entry, source, ctx, finalize, wrapResponse);
|
|
667
|
+
break;
|
|
668
|
+
case "query":
|
|
669
|
+
inner = (runtime, entry, _source, ctx, finalize, wrapResponse) => runtime.runQueryOnly(entry, ctx, finalize, wrapResponse);
|
|
670
|
+
break;
|
|
671
|
+
default:
|
|
672
|
+
inner = (runtime, entry, source, ctx, finalize, wrapResponse) => runtime.runLifecycle(entry, source, ctx, finalize, wrapResponse);
|
|
673
|
+
}
|
|
674
|
+
const execute = hasAround
|
|
675
|
+
? (runtime, entry, source, ctx, finalize, wrapResponse) => runtime.runWithAround(entry, ctx, () => inner(runtime, entry, source, ctx, finalize, wrapResponse), finalize, wrapResponse)
|
|
676
|
+
: inner;
|
|
677
|
+
const run = (runtime, entry, source, params, search, signal, budget, platform, finalize, wrapResponse) => {
|
|
678
|
+
const ctx = new RequestContext(source, params, search, signal, budget, platform, runtime.maxBodyBytes);
|
|
679
|
+
let ledger;
|
|
680
|
+
// The runtime is always present when a route resolved a ledger (enforced at registration).
|
|
681
|
+
const ledgerRuntime = runtime.effectLedgerRuntime;
|
|
682
|
+
if (hasLedger && ledgerRuntime !== undefined) {
|
|
683
|
+
const resolved = entry.ledgered;
|
|
684
|
+
ledger = ledgerRuntime.create(resolved);
|
|
685
|
+
ledgerRuntime.attach(ctx, ledger);
|
|
686
|
+
}
|
|
687
|
+
let outcome = execute(runtime, entry, source, ctx, finalize, wrapResponse);
|
|
688
|
+
if (ledger !== undefined && ledgerRuntime !== undefined) {
|
|
689
|
+
const active = ledger;
|
|
690
|
+
const resolved = entry.ledgered;
|
|
691
|
+
outcome = (outcome instanceof Promise ? outcome : Promise.resolve(outcome)).then((value) => ledgerRuntime.settle(active, resolved, value, (fields) => runtime.logger.error("effect ledger sink failed", fields)));
|
|
692
|
+
}
|
|
693
|
+
return outcome;
|
|
694
|
+
};
|
|
695
|
+
return Object.freeze({ run, fusedWeb });
|
|
696
|
+
}
|
|
697
|
+
/** The idempotency lane's bridge back into the normal matched lanes, resolved to a concrete Response
|
|
698
|
+
* (the lane buffers the body, then replays it through the route's real validation + handler). */
|
|
699
|
+
idempotencyRunLanes(buffered, platform, entry, params, search) {
|
|
700
|
+
return Promise.resolve(this.runMatchedLanes(buffered, platform, entry, params, search, toResponse, IDENTITY_RESPONSE, RESPONSE_TIMEOUT, false));
|
|
701
|
+
}
|
|
702
|
+
/** @internal Symbol-keyed install seam for the `idempotency()` plugin. Off the public typed surface. */
|
|
703
|
+
[INSTALL_IDEMPOTENCY](runtime) {
|
|
704
|
+
this.assertConfigurable("idempotency()");
|
|
705
|
+
this.idempotencyRuntime = runtime;
|
|
706
|
+
}
|
|
707
|
+
/** @internal Symbol-keyed install seam for the `mcp()` plugin. Off the public typed surface. */
|
|
708
|
+
[INSTALL_MCP](runtime) {
|
|
709
|
+
this.assertConfigurable("mcp()");
|
|
710
|
+
this.mcpRuntime = runtime;
|
|
711
|
+
}
|
|
712
|
+
/** @internal Symbol-keyed install seam for the `nodeDirect()` plugin. Off the public typed surface. */
|
|
713
|
+
[INSTALL_NODE_DIRECT](runtime) {
|
|
714
|
+
this.assertConfigurable("nodeDirect()");
|
|
715
|
+
this.nodeOutcomeRuntime = runtime;
|
|
716
|
+
}
|
|
717
|
+
/** @internal Symbol-keyed install seam for the `streaming()` plugin. Off the public typed surface. */
|
|
718
|
+
[INSTALL_SSE](runtime) {
|
|
719
|
+
this.assertConfigurable("streaming()");
|
|
720
|
+
this.sseRuntime = runtime;
|
|
721
|
+
}
|
|
722
|
+
/** @internal Symbol-keyed install seam for the `websocket()` plugin. Off the public typed surface. */
|
|
723
|
+
[INSTALL_WS](runtime) {
|
|
724
|
+
this.assertConfigurable("websocket()");
|
|
725
|
+
this.wsRuntime = runtime;
|
|
1173
726
|
}
|
|
1174
727
|
/**
|
|
1175
728
|
* Merge another server's routes into this one — the composition escape hatch for large apps.
|
|
@@ -1191,15 +744,23 @@ export class Server {
|
|
|
1191
744
|
* those on the parent).
|
|
1192
745
|
*/
|
|
1193
746
|
merge(other) {
|
|
747
|
+
this.assertConfigurable("merge()");
|
|
1194
748
|
const source = other;
|
|
1195
749
|
if (source.wsRouteCount > 0) {
|
|
1196
750
|
throw new RouteConfigError("INVALID_PATH", "merge() does not carry WebSocket routes — register .ws() routes on the parent server");
|
|
1197
751
|
}
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
752
|
+
this.catalog.addBatch(source.catalog.entries().map((route) => this.bindFusedRuntime(route)));
|
|
753
|
+
// Resolved idempotency/ledger route entries carry their own store/sink configuration, while the
|
|
754
|
+
// runtime object supplies the generic execution machinery. Preserve a group's installed runtime
|
|
755
|
+
// when the parent has none so merging cannot silently disable a safety lane. If the parent already
|
|
756
|
+
// has a runtime, either implementation can execute every resolved entry because route-specific
|
|
757
|
+
// options were pinned during registration.
|
|
758
|
+
this.idempotencyRuntime ??= source.idempotencyRuntime;
|
|
759
|
+
this.effectLedgerRuntime ??= source.effectLedgerRuntime;
|
|
760
|
+
this.mcpRuntime ??= source.mcpRuntime;
|
|
761
|
+
this.nodeOutcomeRuntime ??= source.nodeOutcomeRuntime;
|
|
762
|
+
this.sseRuntime ??= source.sseRuntime;
|
|
763
|
+
this.wsRuntime ??= source.wsRuntime;
|
|
1203
764
|
this.onRequestHooks.push(...source.onRequestHooks);
|
|
1204
765
|
this.onResponseHooks.push(...source.onResponseHooks);
|
|
1205
766
|
this.onResponseFinalizedHooks.push(...source.onResponseFinalizedHooks);
|
|
@@ -1208,6 +769,21 @@ export class Server {
|
|
|
1208
769
|
this.mcpPromptList.push(...source.mcpPromptList);
|
|
1209
770
|
return this;
|
|
1210
771
|
}
|
|
772
|
+
/** A fused renderer closes over runtime services to keep its seven-argument JSC fast path. Merging
|
|
773
|
+
* rebinds that closure once to the executing server; generic plans already receive the runtime. */
|
|
774
|
+
bindFusedRuntime(route) {
|
|
775
|
+
const { entry } = route;
|
|
776
|
+
if (entry.execution.fusedWeb === undefined)
|
|
777
|
+
return route;
|
|
778
|
+
const fusedWeb = this.buildFusedWeb(entry.handler, entry.hasDecorations ? entry.decorations : undefined, isContextlessNoArgArrow(entry.handler));
|
|
779
|
+
return {
|
|
780
|
+
...route,
|
|
781
|
+
entry: {
|
|
782
|
+
...entry,
|
|
783
|
+
execution: Object.freeze({ ...entry.execution, fusedWeb }),
|
|
784
|
+
},
|
|
785
|
+
};
|
|
786
|
+
}
|
|
1211
787
|
/**
|
|
1212
788
|
* Enumerate the registered routes (method, path, input schemas), in registration
|
|
1213
789
|
* order. Powers `toOpenAPI` and other introspection; the router trie itself no
|
|
@@ -1215,11 +791,11 @@ export class Server {
|
|
|
1215
791
|
*/
|
|
1216
792
|
routes() {
|
|
1217
793
|
if (this.activeAssurance.length === 0 && this.globalAssurance.length === 0) {
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
794
|
+
if (!this.catalog.hasAssurance()) {
|
|
795
|
+
return this.catalog.routeDescriptors();
|
|
796
|
+
}
|
|
1221
797
|
}
|
|
1222
|
-
return this.
|
|
798
|
+
return this.catalog.entries().map(({ method, path, descriptor, assurance }) => {
|
|
1223
799
|
const effective = assuranceEvidenceFor([...assurance, ...this.globalAssurance], method, path);
|
|
1224
800
|
return effective.length > 0 ? { ...descriptor, assurance: effective } : descriptor;
|
|
1225
801
|
});
|
|
@@ -1242,7 +818,7 @@ export class Server {
|
|
|
1242
818
|
}
|
|
1243
819
|
fetchSourceInner(source, platform) {
|
|
1244
820
|
// Non-`async` on purpose: `dispatch` may return a `Response` *synchronously* (the bare-route fast
|
|
1245
|
-
// path,
|
|
821
|
+
// path, selected by the compiled execution plan), and an `async fetch` would wrap every such result in a redundant
|
|
1246
822
|
// promise + microtask. Returning `Response | Promise<Response>` matches Web/edge handlers, while
|
|
1247
823
|
// `await app.fetch(...)` continues to work exactly as before.
|
|
1248
824
|
const outcome = this.dispatch(source, platform, toResponse, IDENTITY_RESPONSE, RESPONSE_TIMEOUT, true);
|
|
@@ -1321,7 +897,7 @@ export class Server {
|
|
|
1321
897
|
resolveNode(req, platform) {
|
|
1322
898
|
return this.resolveNodeSource(req, platform);
|
|
1323
899
|
}
|
|
1324
|
-
resolveNodeSource(source, platform) {
|
|
900
|
+
resolveNodeSource(source, platform, suppliedRuntime) {
|
|
1325
901
|
// onResponse hooks transform a Response, and the capacity gate wraps the Web response path — both
|
|
1326
902
|
// force the Web path here (the gated `fetchSource` admits/sheds/releases); wrap its result.
|
|
1327
903
|
if (this.onResponseHooks.length > 0 ||
|
|
@@ -1332,10 +908,14 @@ export class Server {
|
|
|
1332
908
|
? response.then((settled) => ({ kind: "response", response: settled }))
|
|
1333
909
|
: { kind: "response", response };
|
|
1334
910
|
}
|
|
1335
|
-
// May resolve **synchronously** for a bare route + sync handler
|
|
911
|
+
// May resolve **synchronously** for a compiled bare route + sync handler - the `@nifrajs/node`
|
|
1336
912
|
// adapter `await`s the result, so it transparently handles either; the sync case allocates no promise
|
|
1337
913
|
// at all on the Node hot path.
|
|
1338
|
-
|
|
914
|
+
const runtime = suppliedRuntime ?? this.nodeOutcomeRuntime;
|
|
915
|
+
if (runtime === undefined) {
|
|
916
|
+
throw new FrameworkError("NODE_DIRECT_RUNTIME_MISSING", "resolveNode() needs the Node-direct renderer. Normal @nifrajs/node serving installs it automatically; direct callers should add `.use(nodeDirect())` (from `@nifrajs/core/node-direct`).");
|
|
917
|
+
}
|
|
918
|
+
return this.dispatch(source, platform, runtime.toOutcome, runtime.fromResponse, runtime.timeout, false);
|
|
1339
919
|
}
|
|
1340
920
|
/**
|
|
1341
921
|
* Resolve a WebSocket upgrade — the seam every serving adapter uses. Returns `pass` (not a WS
|
|
@@ -1356,12 +936,13 @@ export class Server {
|
|
|
1356
936
|
return WS_PASS; // upgrade header, no WS route here → normal routing decides
|
|
1357
937
|
// Inspect only captured values for escapes. Scanning the full pathname repeated work the router
|
|
1358
938
|
// already did and made every plain dynamic route pay for unrelated static path bytes.
|
|
1359
|
-
const params = match.params === EMPTY_PARAMS ? match.params :
|
|
939
|
+
const params = match.params === EMPTY_PARAMS ? match.params : decodeRouteParams(match.params);
|
|
1360
940
|
if (params === null)
|
|
1361
941
|
return { kind: "reject", response: jsonError(400, "malformed_path") };
|
|
1362
942
|
const handler = match.payload.handler;
|
|
1363
|
-
// Non-null: wsRouteCount > 0 ⇒ ws() ran ⇒ the runtime
|
|
943
|
+
// Non-null: wsRouteCount > 0 ⇒ ws() ran ⇒ `.use(websocket())` installed the runtime + registry.
|
|
1364
944
|
const pubsub = this.topics;
|
|
945
|
+
const attach = this.wsRuntime.attach;
|
|
1365
946
|
// CSWSH guard, before any per-connection work or the user's upgrade(): reject a disallowed
|
|
1366
947
|
// Origin with 403. Browsers don't CORS-protect WS handshakes but do send cookies, so this
|
|
1367
948
|
// blocks cross-site authenticated sockets when the route opts in via `allowedOrigins`.
|
|
@@ -1381,13 +962,13 @@ export class Server {
|
|
|
1381
962
|
return { kind: "reject", response: jsonError(403, "forbidden_origin") };
|
|
1382
963
|
}
|
|
1383
964
|
if (handler.upgrade === undefined) {
|
|
1384
|
-
return { kind: "upgrade", handler, data: undefined, pubsub };
|
|
965
|
+
return { kind: "upgrade", handler, data: undefined, pubsub, attach };
|
|
1385
966
|
}
|
|
1386
967
|
const upgradeSignal = getNeverAbortSignal();
|
|
1387
968
|
const ctx = new RequestContext(req, params, url.search, upgradeSignal, createUnboundedRequestBudget(upgradeSignal), platform, this.maxBodyBytes);
|
|
1388
969
|
const settle = (value) => value instanceof Response
|
|
1389
970
|
? { kind: "reject", response: value }
|
|
1390
|
-
: { kind: "upgrade", handler, data: value, pubsub };
|
|
971
|
+
: { kind: "upgrade", handler, data: value, pubsub, attach };
|
|
1391
972
|
try {
|
|
1392
973
|
const result = handler.upgrade(ctx);
|
|
1393
974
|
return result instanceof Promise
|
|
@@ -1407,7 +988,7 @@ export class Server {
|
|
|
1407
988
|
bunFetchWithWebSocket(req, server) {
|
|
1408
989
|
const handle = (o) => {
|
|
1409
990
|
if (o.kind === "pass")
|
|
1410
|
-
return this.fetch(req);
|
|
991
|
+
return this.fetch(req, bunPeerPlatform(server, req));
|
|
1411
992
|
if (o.kind === "reject")
|
|
1412
993
|
return o.response;
|
|
1413
994
|
return server.upgrade(req, { data: { handler: o.handler, data: o.data } })
|
|
@@ -1424,16 +1005,26 @@ export class Server {
|
|
|
1424
1005
|
* produces the 503. The Web `fetch` and `resolveNode` are thin callers over this one routing +
|
|
1425
1006
|
* context + lifecycle implementation — no duplication across the trust boundary.
|
|
1426
1007
|
*/
|
|
1008
|
+
/** Apply the `clientIp` trust declaration to the adapter's raw socket peer, returning a platform
|
|
1009
|
+
* whose `clientIp` is the derived caller. Only called when a trust declaration is configured. */
|
|
1010
|
+
deriveClientIp(source, platform) {
|
|
1011
|
+
const derived = resolveClientIp(platform?.clientIp, requestOf(source), this.clientIpTrust);
|
|
1012
|
+
return { ...platform, clientIp: derived };
|
|
1013
|
+
}
|
|
1427
1014
|
dispatch(source, platform, finalize, wrapResponse, onTimeout,
|
|
1428
1015
|
// True only from the Web `fetch` path — unlocks each route's fused lane, whose output type IS
|
|
1429
1016
|
// `Response` (`T = Response` there by construction; the node path always passes false).
|
|
1430
1017
|
webFast) {
|
|
1018
|
+
// Resolve the trust declaration into the platform's `clientIp` ONCE, here at the shared funnel, so
|
|
1019
|
+
// `c.clientIp` (and every hook/derive downstream) sees the derived caller. No config ⇒ the raw
|
|
1020
|
+
// socket peer the adapter supplied passes through untouched (a one-property no-op on the hot path).
|
|
1021
|
+
const resolved = this.clientIpTrust === undefined ? platform : this.deriveClientIp(source, platform);
|
|
1431
1022
|
// onRequest hooks may be async, so a hooked app takes the async path; with no hooks (the common
|
|
1432
1023
|
// case) routing stays synchronous, letting a bare route resolve with no lifecycle promise at all.
|
|
1433
1024
|
if (this.onRequestHooks.length === 0) {
|
|
1434
|
-
return this.routeAndRun(source,
|
|
1025
|
+
return this.routeAndRun(source, resolved, finalize, wrapResponse, onTimeout, webFast);
|
|
1435
1026
|
}
|
|
1436
|
-
return this.runWithOnRequest(source,
|
|
1027
|
+
return this.runWithOnRequest(source, resolved, finalize, wrapResponse, onTimeout, webFast);
|
|
1437
1028
|
}
|
|
1438
1029
|
/**
|
|
1439
1030
|
* onRequest short-circuit path. Synchronous as long as every hook returns synchronously (the
|
|
@@ -1495,12 +1086,12 @@ export class Server {
|
|
|
1495
1086
|
}
|
|
1496
1087
|
/**
|
|
1497
1088
|
* Route → build context → run. Synchronous through to the handler for a **bare** route
|
|
1498
|
-
* (
|
|
1089
|
+
* (selected by its compiled plan), so a sync handler produces its result with zero promise allocations;
|
|
1499
1090
|
* routes with validation/hooks keep the full async {@link runLifecycle}, unchanged.
|
|
1500
1091
|
*/
|
|
1501
1092
|
routeAndRun(source, platform, finalize, wrapResponse, onTimeout, webFast) {
|
|
1502
1093
|
const url = urlPartsOf(source.url);
|
|
1503
|
-
const match = this.
|
|
1094
|
+
const match = this.catalog.find(source.method, url.pathname);
|
|
1504
1095
|
if (!match.found) {
|
|
1505
1096
|
if (match.reason === "method-not-allowed") {
|
|
1506
1097
|
return wrapResponse(jsonError(405, "method_not_allowed", { Allow: match.allowed.join(", ") }));
|
|
@@ -1509,7 +1100,7 @@ export class Server {
|
|
|
1509
1100
|
}
|
|
1510
1101
|
// Inspect only captured values for escapes. Scanning the full pathname repeated work the router
|
|
1511
1102
|
// already did and made every plain dynamic route pay for unrelated static path bytes.
|
|
1512
|
-
const params = match.params === EMPTY_PARAMS ? match.params :
|
|
1103
|
+
const params = match.params === EMPTY_PARAMS ? match.params : decodeRouteParams(match.params);
|
|
1513
1104
|
if (params === null) {
|
|
1514
1105
|
return wrapResponse(jsonError(400, "malformed_path"));
|
|
1515
1106
|
}
|
|
@@ -1519,115 +1110,16 @@ export class Server {
|
|
|
1519
1110
|
runMatched(source, platform, entry, params, search, finalize, wrapResponse, onTimeout, webFast) {
|
|
1520
1111
|
// An idempotency route runs its dedupe lane first; on a fresh key it delegates to the normal lanes
|
|
1521
1112
|
// (with the body buffered). All non-idempotent routes skip straight to the lanes — no added cost.
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
/**
|
|
1528
|
-
* Idempotency dedupe lane. Reads the `Idempotency-Key`, fingerprints the request, and consults the
|
|
1529
|
-
* store: a fresh key runs the handler (via {@link runMatchedLanes} with the body buffered) and stores
|
|
1530
|
-
* the response; a repeat key replays it without re-running; a key reused with a different body or an
|
|
1531
|
-
* in-flight duplicate is rejected. Every branch materializes a `Response`, then adapts to `T` via the
|
|
1532
|
-
* caller's `wrapResponse`, so it works on the Web fetch path and the Node-native outcome path alike.
|
|
1533
|
-
*/
|
|
1534
|
-
async runIdempotent(source, platform, entry, params, search, wrapResponse) {
|
|
1535
|
-
const config = entry.idempotent;
|
|
1536
|
-
const req = requestOf(source);
|
|
1537
|
-
const key = req.headers.get(config.headerName);
|
|
1538
|
-
if (key === null || !validIdempotencyKey(key)) {
|
|
1539
|
-
return wrapResponse(jsonError(400, "idempotency_key_required"));
|
|
1540
|
-
}
|
|
1541
|
-
const namespace = typeof config.namespace === "function"
|
|
1542
|
-
? await config.namespace(req.clone(), platform)
|
|
1543
|
-
: config.namespace;
|
|
1544
|
-
if (!validIdempotencyNamespace(namespace)) {
|
|
1545
|
-
return wrapResponse(jsonError(500, "idempotency_namespace_invalid"));
|
|
1546
|
-
}
|
|
1547
|
-
const read = await readBoundedBytes(req, this.maxBodyBytes);
|
|
1548
|
-
if (!read.ok) {
|
|
1549
|
-
return wrapResponse(jsonError(read.status, read.status === 413 ? "payload_too_large" : "bad_request"));
|
|
1550
|
-
}
|
|
1551
|
-
const url = urlPartsOf(req.url);
|
|
1552
|
-
const canonicalBody = canonicalizeIdempotencyBody(read.bytes, req.headers.get("content-type"));
|
|
1553
|
-
const fingerprint = await computeIdempotencyFingerprint(req.method, `${url.pathname}${url.search}`, canonicalBody, req.headers.get("content-type") ?? "");
|
|
1554
|
-
const begin = await config.store.begin({ namespace, key, fingerprint, ttlMs: config.ttlMs });
|
|
1555
|
-
if (begin.state === "replay") {
|
|
1556
|
-
return wrapResponse(responseFromStored(begin.response, { maxBytes: config.maxResponseBytes }));
|
|
1557
|
-
}
|
|
1558
|
-
if (begin.state === "mismatch")
|
|
1559
|
-
return wrapResponse(jsonError(409, "idempotency_key_reused"));
|
|
1560
|
-
if (begin.state === "in-flight") {
|
|
1561
|
-
return wrapResponse(jsonError(409, "idempotency_in_progress", { "Retry-After": "1" }));
|
|
1562
|
-
}
|
|
1563
|
-
if (begin.state === "capacity") {
|
|
1564
|
-
return wrapResponse(jsonError(503, "idempotency_store_capacity", { "Retry-After": "1" }));
|
|
1565
|
-
}
|
|
1566
|
-
const reservation = begin.reservation;
|
|
1567
|
-
// Fresh key: re-expose the buffered body as a Request so the normal lanes can read + validate it,
|
|
1568
|
-
// then run those lanes to a concrete Response.
|
|
1569
|
-
const bufferedInit = {
|
|
1570
|
-
method: req.method,
|
|
1571
|
-
headers: req.headers,
|
|
1572
|
-
signal: req.signal,
|
|
1573
|
-
};
|
|
1574
|
-
if (read.bytes.byteLength > 0)
|
|
1575
|
-
bufferedInit.body = read.bytes;
|
|
1576
|
-
const buffered = new Request(req.url, bufferedInit);
|
|
1577
|
-
let response;
|
|
1578
|
-
try {
|
|
1579
|
-
response = await this.runMatchedLanes(buffered, platform, entry, params, search, toResponse, IDENTITY_RESPONSE, RESPONSE_TIMEOUT, false);
|
|
1580
|
-
}
|
|
1581
|
-
catch (err) {
|
|
1582
|
-
// The lanes never throw (errors resolve to a 500 Response), but stay fail-safe: release the
|
|
1583
|
-
// reservation so a retry isn't wedged, then re-throw.
|
|
1584
|
-
await config.store.abandon({ namespace, key, reservation });
|
|
1585
|
-
throw err;
|
|
1586
|
-
}
|
|
1587
|
-
// Cache only successful responses; release the key on any error so the client can retry.
|
|
1588
|
-
if (response.status >= 200 && response.status < 300) {
|
|
1589
|
-
let storedResponse;
|
|
1590
|
-
try {
|
|
1591
|
-
storedResponse = await serializeResponse(response, { maxBytes: config.maxResponseBytes });
|
|
1592
|
-
}
|
|
1593
|
-
catch (error) {
|
|
1594
|
-
if (!(error instanceof IdempotencyResponseTooLargeError))
|
|
1595
|
-
throw error;
|
|
1596
|
-
// The effect may already have happened, so never abandon and permit a duplicate execution.
|
|
1597
|
-
// Commit a small terminal response under the winning key and return that same response now.
|
|
1598
|
-
response = jsonError(507, "idempotency_response_too_large");
|
|
1599
|
-
try {
|
|
1600
|
-
storedResponse = await serializeResponse(response, {
|
|
1601
|
-
maxBytes: config.maxResponseBytes,
|
|
1602
|
-
});
|
|
1603
|
-
}
|
|
1604
|
-
catch (terminalError) {
|
|
1605
|
-
if (!(terminalError instanceof IdempotencyResponseTooLargeError))
|
|
1606
|
-
throw terminalError;
|
|
1607
|
-
// Even an intentionally tiny bound must remain truthful. An empty 507 preserves terminal
|
|
1608
|
-
// status + replay safety without silently storing more bytes than the route permits.
|
|
1609
|
-
response = new Response(null, { status: 507 });
|
|
1610
|
-
storedResponse = await serializeResponse(response, {
|
|
1611
|
-
maxBytes: config.maxResponseBytes,
|
|
1612
|
-
});
|
|
1613
|
-
}
|
|
1614
|
-
}
|
|
1615
|
-
const completed = await config.store.complete({
|
|
1616
|
-
namespace,
|
|
1617
|
-
key,
|
|
1618
|
-
reservation,
|
|
1619
|
-
response: storedResponse,
|
|
1113
|
+
// The runtime is always present when a route resolved idempotency (enforced at registration).
|
|
1114
|
+
if (entry.idempotent !== undefined && this.idempotencyRuntime !== undefined) {
|
|
1115
|
+
return this.idempotencyRuntime.run(entry.idempotent, requestOf(source), platform, entry, params, search, wrapResponse, {
|
|
1116
|
+
maxBodyBytes: this.maxBodyBytes,
|
|
1117
|
+
runLanes: (buffered, plat, ent, prm, srch) => this.idempotencyRunLanes(buffered, plat, ent, prm, srch),
|
|
1620
1118
|
});
|
|
1621
|
-
if (!completed) {
|
|
1622
|
-
return wrapResponse(jsonError(503, "idempotency_reservation_lost", { "Retry-After": "1" }));
|
|
1623
|
-
}
|
|
1624
|
-
}
|
|
1625
|
-
else {
|
|
1626
|
-
await config.store.abandon({ namespace, key, reservation });
|
|
1627
1119
|
}
|
|
1628
|
-
return wrapResponse
|
|
1120
|
+
return this.runMatchedLanes(source, platform, entry, params, search, finalize, wrapResponse, onTimeout, webFast);
|
|
1629
1121
|
}
|
|
1630
|
-
/**
|
|
1122
|
+
/** Supply request-specific deadline state to the route's precompiled execution plan. */
|
|
1631
1123
|
runMatchedLanes(source, platform, entry, params, search, finalize, wrapResponse, onTimeout, webFast) {
|
|
1632
1124
|
// Translate the absolute wire deadline once, clamp it to local policy, then use the resulting
|
|
1633
1125
|
// duration for both c.signal and c.budget. A client can only shorten work, never extend it.
|
|
@@ -1661,62 +1153,10 @@ export class Server {
|
|
|
1661
1153
|
const budget = controller === undefined
|
|
1662
1154
|
? getUnboundedRequestBudget()
|
|
1663
1155
|
: createRequestBudget({ deadline: admission.deadline, signal });
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
// Fused Web lane (bare route, no around): one closure end to end. Sound cast — webFast is
|
|
1669
|
-
// passed only by the `fetch` path, where T = Response by construction.
|
|
1670
|
-
outcome = entry.fusedWeb(source, params, search, signal, budget, platform, false);
|
|
1671
|
-
}
|
|
1672
|
-
else if (entry.contextlessBare) {
|
|
1673
|
-
outcome = this.runContextlessBare(entry, source, params, search, signal, budget, platform, finalize, wrapResponse);
|
|
1674
|
-
}
|
|
1675
|
-
else {
|
|
1676
|
-
// Build the context up front so `onError` always has it. Query, cookies, headers, and body helpers
|
|
1677
|
-
// stay lazy, but their accessors live on the prototype instead of allocating per-request closures.
|
|
1678
|
-
const ctx = new RequestContext(source, params, search, signal, budget, platform, this.maxBodyBytes);
|
|
1679
|
-
// Ledgered routes (capabilities + effectLedger) carry a bounded per-request ledger; the
|
|
1680
|
-
// `useCapability` beacon appends to it, and the settle step below seals + sinks it. The route
|
|
1681
|
-
// was forced off the fused/contextless lanes at registration, so this branch always runs.
|
|
1682
|
-
let ledger;
|
|
1683
|
-
if (entry.ledgered !== undefined) {
|
|
1684
|
-
ledger = createRequestLedger({
|
|
1685
|
-
method: entry.ledgered.method,
|
|
1686
|
-
path: entry.ledgered.path,
|
|
1687
|
-
declared: entry.ledgered.declared,
|
|
1688
|
-
maxEntries: entry.ledgered.maxEntries,
|
|
1689
|
-
chain: entry.ledgered.chain,
|
|
1690
|
-
});
|
|
1691
|
-
attachEffectLedger(ctx, ledger);
|
|
1692
|
-
}
|
|
1693
|
-
if (entry.around.length === 0) {
|
|
1694
|
-
outcome = entry.bare
|
|
1695
|
-
? this.runBare(entry, ctx, finalize, wrapResponse)
|
|
1696
|
-
: entry.bodyOnly
|
|
1697
|
-
? this.runBodyOnly(entry, source, ctx, finalize, wrapResponse)
|
|
1698
|
-
: entry.queryOnly
|
|
1699
|
-
? this.runQueryOnly(entry, ctx, finalize, wrapResponse)
|
|
1700
|
-
: this.runLifecycle(entry, source, ctx, finalize, wrapResponse);
|
|
1701
|
-
}
|
|
1702
|
-
else {
|
|
1703
|
-
outcome = this.runWithAround(entry, ctx, () => entry.bare
|
|
1704
|
-
? this.runBare(entry, ctx, finalize, wrapResponse)
|
|
1705
|
-
: entry.bodyOnly
|
|
1706
|
-
? this.runBodyOnly(entry, source, ctx, finalize, wrapResponse)
|
|
1707
|
-
: entry.queryOnly
|
|
1708
|
-
? this.runQueryOnly(entry, ctx, finalize, wrapResponse)
|
|
1709
|
-
: this.runLifecycle(entry, source, ctx, finalize, wrapResponse), finalize, wrapResponse);
|
|
1710
|
-
}
|
|
1711
|
-
// Settle step: seal the ledger and hand it to the sink BEFORE the response is released, on
|
|
1712
|
-
// success and error responses alike (partial work is exactly what an audit must capture). The
|
|
1713
|
-
// lanes above resolve handler errors to responses, so a plain resolve arm is sufficient.
|
|
1714
|
-
if (ledger !== undefined) {
|
|
1715
|
-
const resolved = entry.ledgered;
|
|
1716
|
-
const active = ledger;
|
|
1717
|
-
outcome = (outcome instanceof Promise ? outcome : Promise.resolve(outcome)).then((value) => this.settleEffectLedger(active, resolved, value));
|
|
1718
|
-
}
|
|
1719
|
-
}
|
|
1156
|
+
const plan = entry.execution;
|
|
1157
|
+
const outcome = webFast && plan.fusedWeb !== undefined
|
|
1158
|
+
? plan.fusedWeb(source, params, search, signal, budget, platform, false)
|
|
1159
|
+
: plan.run(this, entry, source, params, search, signal, budget, platform, finalize, wrapResponse);
|
|
1720
1160
|
// The request timeout only bounds work that is actually pending — a synchronous (bare) result is
|
|
1721
1161
|
// already complete and can't time out, so it's returned as-is (no 503 race, no promise).
|
|
1722
1162
|
if (controller !== undefined && outcome instanceof Promise) {
|
|
@@ -1727,30 +1167,10 @@ export class Server {
|
|
|
1727
1167
|
}
|
|
1728
1168
|
return outcome;
|
|
1729
1169
|
}
|
|
1730
|
-
/**
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
* the owned effect transaction, so a sink outage must not turn a successful effect into a retryable
|
|
1735
|
-
* 500 that can duplicate it.
|
|
1736
|
-
*/
|
|
1737
|
-
async settleEffectLedger(ledger, resolved, value) {
|
|
1738
|
-
const sealed = await ledger.seal();
|
|
1739
|
-
if (sealed.entries.length === 0)
|
|
1740
|
-
return value;
|
|
1741
|
-
try {
|
|
1742
|
-
await resolved.sink(sealed);
|
|
1743
|
-
}
|
|
1744
|
-
catch (err) {
|
|
1745
|
-
// Token-only logging: the route pattern and the failure name — never entry payloads (there are
|
|
1746
|
-
// none) and never the request.
|
|
1747
|
-
this.logger.error("effect ledger sink failed", {
|
|
1748
|
-
method: resolved.method,
|
|
1749
|
-
path: resolved.path,
|
|
1750
|
-
name: err instanceof Error ? err.name : "Error",
|
|
1751
|
-
});
|
|
1752
|
-
}
|
|
1753
|
-
return value;
|
|
1170
|
+
/** @internal Symbol-keyed install seam for the `effectLedger()` plugin. Off the public typed surface. */
|
|
1171
|
+
[INSTALL_EFFECT_LEDGER](runtime) {
|
|
1172
|
+
this.assertConfigurable("effectLedger()");
|
|
1173
|
+
this.effectLedgerRuntime = runtime;
|
|
1754
1174
|
}
|
|
1755
1175
|
/** The narrowest bare route: a syntactic `() => ...` handler cannot observe the context argument, so
|
|
1756
1176
|
* successful requests can skip allocating `RequestContext`. Errors still allocate one for logging. */
|
|
@@ -1775,7 +1195,7 @@ export class Server {
|
|
|
1775
1195
|
return wrapResponse(jsonError(500, "internal_error"));
|
|
1776
1196
|
}
|
|
1777
1197
|
/**
|
|
1778
|
-
* The synchronous fast path
|
|
1198
|
+
* The synchronous fast path selected by a route's execution plan: apply static decorations, call the
|
|
1779
1199
|
* handler, render the result — **no `await`** unless the handler itself returns a promise. It mirrors
|
|
1780
1200
|
* the bare slice of {@link runLifecycle} (which a bare route would otherwise no-op through) and shares
|
|
1781
1201
|
* {@link logRequestError}; a bare route has no `onError` hooks, so error handling is fully synchronous
|
|
@@ -1801,14 +1221,13 @@ export class Server {
|
|
|
1801
1221
|
/** Bare-route error rendering — identical to {@link runLifecycle}'s catch minus the (absent) onError
|
|
1802
1222
|
* loop: a thrown `Response` is returned as deliberate control flow; anything else is logged + 500. */
|
|
1803
1223
|
/**
|
|
1804
|
-
* Build a route's fused Web
|
|
1224
|
+
* Build a route's fused Web renderer. Composition happens once at
|
|
1805
1225
|
* registration; the returned closure is what every request to the route runs. Behavior is
|
|
1806
1226
|
* byte-identical to the generic `runBare`/`runContextlessBare` + `toResponse` pair — same
|
|
1807
1227
|
* decoration order, same error routing (thrown `Response` = control flow; anything else logs and
|
|
1808
1228
|
* 500s), same respond semantics (the lifecycle parity suite pins it).
|
|
1809
1229
|
*/
|
|
1810
1230
|
buildFusedWeb(handler, decorations, contextless) {
|
|
1811
|
-
const maxBodyBytes = this.maxBodyBytes;
|
|
1812
1231
|
const logError = (err, ctx) => {
|
|
1813
1232
|
if (err instanceof Response)
|
|
1814
1233
|
return err;
|
|
@@ -1826,21 +1245,21 @@ export class Server {
|
|
|
1826
1245
|
}
|
|
1827
1246
|
catch (err) {
|
|
1828
1247
|
return logError(err, nativeContext
|
|
1829
|
-
? RequestContext.native(source, params, maxBodyBytes)
|
|
1830
|
-
: new RequestContext(source, params, search, signal, budget, platform, maxBodyBytes));
|
|
1248
|
+
? RequestContext.native(source, params, this.maxBodyBytes)
|
|
1249
|
+
: new RequestContext(source, params, search, signal, budget, platform, this.maxBodyBytes));
|
|
1831
1250
|
}
|
|
1832
1251
|
if (result instanceof Promise) {
|
|
1833
1252
|
return result.then(fusedRespondNoSet, (err) => logError(err, nativeContext
|
|
1834
|
-
? RequestContext.native(source, params, maxBodyBytes)
|
|
1835
|
-
: new RequestContext(source, params, search, signal, budget, platform, maxBodyBytes)));
|
|
1253
|
+
? RequestContext.native(source, params, this.maxBodyBytes)
|
|
1254
|
+
: new RequestContext(source, params, search, signal, budget, platform, this.maxBodyBytes)));
|
|
1836
1255
|
}
|
|
1837
1256
|
return fusedRespondNoSet(result);
|
|
1838
1257
|
};
|
|
1839
1258
|
}
|
|
1840
1259
|
return (source, params, search, signal, budget, platform, nativeContext) => {
|
|
1841
1260
|
const ctx = nativeContext
|
|
1842
|
-
? RequestContext.native(source, params, maxBodyBytes)
|
|
1843
|
-
: new RequestContext(source, params, search, signal, budget, platform, maxBodyBytes);
|
|
1261
|
+
? RequestContext.native(source, params, this.maxBodyBytes)
|
|
1262
|
+
: new RequestContext(source, params, search, signal, budget, platform, this.maxBodyBytes);
|
|
1844
1263
|
if (decorations !== undefined)
|
|
1845
1264
|
Object.assign(ctx, decorations);
|
|
1846
1265
|
let result;
|
|
@@ -2179,8 +1598,18 @@ export class Server {
|
|
|
2179
1598
|
*/
|
|
2180
1599
|
async runLifecycle(entry, source, ctx, finalize, wrapResponse) {
|
|
2181
1600
|
try {
|
|
1601
|
+
if (entry.schema?.params !== undefined) {
|
|
1602
|
+
// Path params arrive as strings from routing; validate (and coerce) them at the boundary, before
|
|
1603
|
+
// the body/query, so a malformed `:id` is a 422 before any work runs. Raw `.validate` (no wrapper
|
|
1604
|
+
// alloc), same recovery-hook path as body/query.
|
|
1605
|
+
const validation = entry.schema.params["~standard"].validate(ctx.params);
|
|
1606
|
+
const result = validation instanceof Promise ? await validation : validation;
|
|
1607
|
+
const paramsError = await this.applyLifecycleValidation(entry, result, ctx, "params");
|
|
1608
|
+
if (paramsError !== undefined)
|
|
1609
|
+
return wrapResponse(paramsError);
|
|
1610
|
+
}
|
|
2182
1611
|
if (entry.schema?.body !== undefined) {
|
|
2183
|
-
const bodyError = await this.readAndValidateBody(source, entry
|
|
1612
|
+
const bodyError = await this.readAndValidateBody(source, entry, ctx);
|
|
2184
1613
|
if (bodyError !== undefined)
|
|
2185
1614
|
return wrapResponse(bodyError);
|
|
2186
1615
|
}
|
|
@@ -2190,9 +1619,9 @@ export class Server {
|
|
|
2190
1619
|
// validateStandard wrapper alloc.
|
|
2191
1620
|
const validation = entry.schema.query["~standard"].validate(queryObjectOf(ctx[CONTEXT_SEARCH]));
|
|
2192
1621
|
const result = validation instanceof Promise ? await validation : validation;
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
1622
|
+
const queryError = await this.applyLifecycleValidation(entry, result, ctx, "query");
|
|
1623
|
+
if (queryError !== undefined)
|
|
1624
|
+
return wrapResponse(queryError);
|
|
2196
1625
|
}
|
|
2197
1626
|
// Context extensions: static decorations, then per-request derives.
|
|
2198
1627
|
// Each hook below only awaits when it actually returns a Promise — a sync hook
|
|
@@ -2262,7 +1691,7 @@ export class Server {
|
|
|
2262
1691
|
stack: err instanceof Error ? err.stack : undefined,
|
|
2263
1692
|
});
|
|
2264
1693
|
}
|
|
2265
|
-
async readAndValidateBody(req,
|
|
1694
|
+
async readAndValidateBody(req, entry, ctx) {
|
|
2266
1695
|
const contentType = headerOf(req, "content-type") ?? "";
|
|
2267
1696
|
let parsed;
|
|
2268
1697
|
if (contentType === "application/json" || contentType.includes("application/json")) {
|
|
@@ -2282,11 +1711,44 @@ export class Server {
|
|
|
2282
1711
|
// fit a value schema; use a schema-less route + @nifrajs/uploads helpers for those.
|
|
2283
1712
|
return jsonError(415, "unsupported_media_type");
|
|
2284
1713
|
}
|
|
2285
|
-
const validation = schema["~standard"].validate(parsed);
|
|
1714
|
+
const validation = entry.schema.body["~standard"].validate(parsed);
|
|
2286
1715
|
const result = validation instanceof Promise ? await validation : validation;
|
|
2287
|
-
|
|
1716
|
+
return this.applyLifecycleValidation(entry, result, ctx, "body");
|
|
1717
|
+
}
|
|
1718
|
+
/** Apply validation and its recovery hook on the generic lifecycle lane. Recovery is completed
|
|
1719
|
+
* before derives/beforeHandle run, matching the body-only and query-only execution lanes. */
|
|
1720
|
+
async applyLifecycleValidation(entry, result, ctx, kind) {
|
|
1721
|
+
const assign = (value) => {
|
|
1722
|
+
if (kind === "body")
|
|
1723
|
+
ctx.body = value;
|
|
1724
|
+
else if (kind === "query")
|
|
1725
|
+
ctx.query = value;
|
|
1726
|
+
else
|
|
1727
|
+
ctx.params = value;
|
|
1728
|
+
};
|
|
1729
|
+
if (result.issues === undefined) {
|
|
1730
|
+
assign(result.value);
|
|
1731
|
+
return undefined;
|
|
1732
|
+
}
|
|
1733
|
+
const hook = entry.schema?.onValidationError ?? this.defaultOnValidationError;
|
|
1734
|
+
if (hook === undefined)
|
|
2288
1735
|
return validationError(result.issues);
|
|
2289
|
-
|
|
1736
|
+
const attempted = hook(result.issues, ctx, kind);
|
|
1737
|
+
const recovery = attempted instanceof Promise ? await attempted : attempted;
|
|
1738
|
+
if (recovery === undefined)
|
|
1739
|
+
return validationError(result.issues);
|
|
1740
|
+
if (recovery instanceof Response)
|
|
1741
|
+
return recovery;
|
|
1742
|
+
const schema = kind === "body"
|
|
1743
|
+
? entry.schema?.body
|
|
1744
|
+
: kind === "query"
|
|
1745
|
+
? entry.schema?.query
|
|
1746
|
+
: entry.schema?.params;
|
|
1747
|
+
const retried = schema["~standard"].validate(recovery);
|
|
1748
|
+
const settled = retried instanceof Promise ? await retried : retried;
|
|
1749
|
+
if (settled.issues !== undefined)
|
|
1750
|
+
return validationError(settled.issues);
|
|
1751
|
+
assign(settled.value);
|
|
2290
1752
|
return undefined;
|
|
2291
1753
|
}
|
|
2292
1754
|
/**
|
|
@@ -2306,13 +1768,61 @@ export class Server {
|
|
|
2306
1768
|
async readBoundedJson(req) {
|
|
2307
1769
|
return readBoundedJsonSource(req, this.maxBodyBytes);
|
|
2308
1770
|
}
|
|
1771
|
+
/** Adapt one route's compiled execution plan to Bun's already-matched request shape. Route
|
|
1772
|
+
* semantics remain in the plan; this closure only supplies native params and deadline fallback. */
|
|
1773
|
+
compileBunNativeHandler(entry, paramNames, fused, signal, budget) {
|
|
1774
|
+
if (paramNames.length === 0) {
|
|
1775
|
+
if (fused === undefined) {
|
|
1776
|
+
return (request) => this.fetchMatched(request, entry, EMPTY_PARAMS);
|
|
1777
|
+
}
|
|
1778
|
+
if (this.acceptInboundDeadlines) {
|
|
1779
|
+
return (request) => request.headers.get(NIFRA_DEADLINE_HEADER) !== null
|
|
1780
|
+
? this.fetchMatched(request, entry, EMPTY_PARAMS)
|
|
1781
|
+
: fused(request, EMPTY_PARAMS, undefined, signal, budget, undefined, true);
|
|
1782
|
+
}
|
|
1783
|
+
return (request) => fused(request, EMPTY_PARAMS, undefined, signal, budget, undefined, true);
|
|
1784
|
+
}
|
|
1785
|
+
const malformed = paramNames.length === 1
|
|
1786
|
+
? (params) => params[paramNames[0]]?.includes("\uFFFD") === true
|
|
1787
|
+
: hasReplacementParam;
|
|
1788
|
+
if (fused === undefined) {
|
|
1789
|
+
return (request) => {
|
|
1790
|
+
const params = request.params ?? EMPTY_PARAMS;
|
|
1791
|
+
if (malformed(params))
|
|
1792
|
+
return this.fetchSource(request);
|
|
1793
|
+
return this.fetchMatched(request, entry, params);
|
|
1794
|
+
};
|
|
1795
|
+
}
|
|
1796
|
+
if (this.acceptInboundDeadlines) {
|
|
1797
|
+
return (request) => {
|
|
1798
|
+
const params = request.params ?? EMPTY_PARAMS;
|
|
1799
|
+
if (malformed(params))
|
|
1800
|
+
return this.fetchSource(request);
|
|
1801
|
+
return request.headers.get(NIFRA_DEADLINE_HEADER) !== null
|
|
1802
|
+
? this.fetchMatched(request, entry, params)
|
|
1803
|
+
: fused(request, params, undefined, signal, budget, undefined, true);
|
|
1804
|
+
};
|
|
1805
|
+
}
|
|
1806
|
+
return (request) => {
|
|
1807
|
+
const params = request.params ?? EMPTY_PARAMS;
|
|
1808
|
+
if (malformed(params))
|
|
1809
|
+
return this.fetchSource(request);
|
|
1810
|
+
return fused(request, params, undefined, signal, budget, undefined, true);
|
|
1811
|
+
};
|
|
1812
|
+
}
|
|
2309
1813
|
/** Compile portable route registrations into Bun's native route table. Apps with request-rewrite
|
|
2310
1814
|
* hooks or WebSockets retain the single portable dispatcher because those features must run before
|
|
2311
1815
|
* route selection/upgrade. Named wildcards also stay on the fallback until Bun exposes their raw
|
|
2312
1816
|
* capture semantics; static and `:param` routes take the native lane. */
|
|
2313
1817
|
buildBunNativeRoutes() {
|
|
2314
|
-
|
|
1818
|
+
// A `clientIp` trust declaration must run the resolver in `dispatch`, which the fused native lane
|
|
1819
|
+
// bypasses — so an app that declares trust routes through the fetch lane (where `c.clientIp`
|
|
1820
|
+
// resolves) instead of Bun's native table. The allocation-free default keeps native fusion.
|
|
1821
|
+
if (this.onRequestHooks.length > 0 ||
|
|
1822
|
+
this.wsRouteCount > 0 ||
|
|
1823
|
+
this.clientIpTrust !== undefined) {
|
|
2315
1824
|
return undefined;
|
|
1825
|
+
}
|
|
2316
1826
|
const routes = Object.create(null);
|
|
2317
1827
|
const mayUseFusedNative = this.requestTimeoutMs === 0 &&
|
|
2318
1828
|
this.onResponseHooks.length === 0 &&
|
|
@@ -2323,119 +1833,17 @@ export class Server {
|
|
|
2323
1833
|
const unboundedSignal = mayUseFusedNative ? getNeverAbortSignal() : undefined;
|
|
2324
1834
|
const unboundedBudget = mayUseFusedNative ? getUnboundedRequestBudget() : undefined;
|
|
2325
1835
|
let count = 0;
|
|
2326
|
-
for (const { method, path, entry } of this.
|
|
2327
|
-
if (
|
|
1836
|
+
for (const { method, path, pattern, entry } of this.catalog.entries()) {
|
|
1837
|
+
if (pattern.segments.some((segment) => segment.kind === "wildcard"))
|
|
2328
1838
|
continue;
|
|
2329
1839
|
let methods = routes[path];
|
|
2330
1840
|
if (methods === undefined) {
|
|
2331
1841
|
methods = Object.create(null);
|
|
2332
1842
|
routes[path] = methods;
|
|
2333
1843
|
}
|
|
2334
|
-
const paramNames =
|
|
2335
|
-
|
|
2336
|
-
|
|
2337
|
-
.map((segment) => segment.slice(1));
|
|
2338
|
-
const fused = mayUseFusedNative ? entry.fusedWeb : undefined;
|
|
2339
|
-
if (fused !== undefined) {
|
|
2340
|
-
// Deliberately choose deadline-aware vs zero-admission closures HERE instead of branching on
|
|
2341
|
-
// `acceptInboundDeadlines` per request. JSC can then flatten the default closure through the
|
|
2342
|
-
// handler + Response.json; retaining that route-invariant branch cost ~7% on a dynamic GET.
|
|
2343
|
-
if (paramNames.length === 0) {
|
|
2344
|
-
methods[method] = this.acceptInboundDeadlines
|
|
2345
|
-
? (request) => request.headers.get(NIFRA_DEADLINE_HEADER) !== null
|
|
2346
|
-
? this.fetchMatched(request, entry, EMPTY_PARAMS)
|
|
2347
|
-
: fused(request, EMPTY_PARAMS, undefined, unboundedSignal, unboundedBudget, undefined, true)
|
|
2348
|
-
: (request) => fused(request, EMPTY_PARAMS, undefined, unboundedSignal, unboundedBudget, undefined, true);
|
|
2349
|
-
}
|
|
2350
|
-
else if (paramNames.length === 1) {
|
|
2351
|
-
const paramName = paramNames[0];
|
|
2352
|
-
const handler = entry.handler;
|
|
2353
|
-
const decorations = entry.hasDecorations ? entry.decorations : undefined;
|
|
2354
|
-
const fail = (error, ctx) => {
|
|
2355
|
-
if (error instanceof Response)
|
|
2356
|
-
return error;
|
|
2357
|
-
this.logRequestError(error, ctx);
|
|
2358
|
-
return jsonError(500, "internal_error");
|
|
2359
|
-
};
|
|
2360
|
-
methods[method] = this.acceptInboundDeadlines
|
|
2361
|
-
? (request) => {
|
|
2362
|
-
const params = request.params ?? EMPTY_PARAMS;
|
|
2363
|
-
if (params[paramName]?.includes("\uFFFD") === true)
|
|
2364
|
-
return this.fetchSource(request);
|
|
2365
|
-
if (request.headers.get(NIFRA_DEADLINE_HEADER) !== null) {
|
|
2366
|
-
return this.fetchMatched(request, entry, params);
|
|
2367
|
-
}
|
|
2368
|
-
const ctx = RequestContext.native(request, params, this.maxBodyBytes);
|
|
2369
|
-
if (decorations !== undefined)
|
|
2370
|
-
Object.assign(ctx, decorations);
|
|
2371
|
-
let result;
|
|
2372
|
-
try {
|
|
2373
|
-
result = handler(ctx);
|
|
2374
|
-
}
|
|
2375
|
-
catch (error) {
|
|
2376
|
-
return fail(error, ctx);
|
|
2377
|
-
}
|
|
2378
|
-
return result instanceof Promise
|
|
2379
|
-
? result.then((value) => fusedRespond(value, ctx), (error) => fail(error, ctx))
|
|
2380
|
-
: fusedRespond(result, ctx);
|
|
2381
|
-
}
|
|
2382
|
-
: (request) => {
|
|
2383
|
-
const params = request.params ?? EMPTY_PARAMS;
|
|
2384
|
-
if (params[paramName]?.includes("\uFFFD") === true)
|
|
2385
|
-
return this.fetchSource(request);
|
|
2386
|
-
const ctx = RequestContext.native(request, params, this.maxBodyBytes);
|
|
2387
|
-
if (decorations !== undefined)
|
|
2388
|
-
Object.assign(ctx, decorations);
|
|
2389
|
-
let result;
|
|
2390
|
-
try {
|
|
2391
|
-
result = handler(ctx);
|
|
2392
|
-
}
|
|
2393
|
-
catch (error) {
|
|
2394
|
-
return fail(error, ctx);
|
|
2395
|
-
}
|
|
2396
|
-
return result instanceof Promise
|
|
2397
|
-
? result.then((value) => fusedRespond(value, ctx), (error) => fail(error, ctx))
|
|
2398
|
-
: fusedRespond(result, ctx);
|
|
2399
|
-
};
|
|
2400
|
-
}
|
|
2401
|
-
else {
|
|
2402
|
-
methods[method] = this.acceptInboundDeadlines
|
|
2403
|
-
? (request) => {
|
|
2404
|
-
const params = request.params ?? EMPTY_PARAMS;
|
|
2405
|
-
if (hasReplacementParam(params))
|
|
2406
|
-
return this.fetchSource(request);
|
|
2407
|
-
return request.headers.get(NIFRA_DEADLINE_HEADER) !== null
|
|
2408
|
-
? this.fetchMatched(request, entry, params)
|
|
2409
|
-
: fused(request, params, undefined, unboundedSignal, unboundedBudget, undefined, true);
|
|
2410
|
-
}
|
|
2411
|
-
: (request) => {
|
|
2412
|
-
const params = request.params ?? EMPTY_PARAMS;
|
|
2413
|
-
if (hasReplacementParam(params))
|
|
2414
|
-
return this.fetchSource(request);
|
|
2415
|
-
return fused(request, params, undefined, unboundedSignal, unboundedBudget, undefined, true);
|
|
2416
|
-
};
|
|
2417
|
-
}
|
|
2418
|
-
}
|
|
2419
|
-
else if (paramNames.length === 0) {
|
|
2420
|
-
methods[method] = (request) => this.fetchMatched(request, entry, EMPTY_PARAMS);
|
|
2421
|
-
}
|
|
2422
|
-
else if (paramNames.length === 1) {
|
|
2423
|
-
const paramName = paramNames[0];
|
|
2424
|
-
methods[method] = (request) => {
|
|
2425
|
-
const params = request.params ?? EMPTY_PARAMS;
|
|
2426
|
-
if (params[paramName]?.includes("\uFFFD") === true)
|
|
2427
|
-
return this.fetchSource(request);
|
|
2428
|
-
return this.fetchMatched(request, entry, params);
|
|
2429
|
-
};
|
|
2430
|
-
}
|
|
2431
|
-
else {
|
|
2432
|
-
methods[method] = (request) => {
|
|
2433
|
-
const params = request.params ?? EMPTY_PARAMS;
|
|
2434
|
-
if (hasReplacementParam(params))
|
|
2435
|
-
return this.fetchSource(request);
|
|
2436
|
-
return this.fetchMatched(request, entry, params);
|
|
2437
|
-
};
|
|
2438
|
-
}
|
|
1844
|
+
const paramNames = pattern.paramNames;
|
|
1845
|
+
const fused = mayUseFusedNative ? entry.execution.fusedWeb : undefined;
|
|
1846
|
+
methods[method] = this.compileBunNativeHandler(entry, paramNames, fused, unboundedSignal, unboundedBudget);
|
|
2439
1847
|
count += 1;
|
|
2440
1848
|
}
|
|
2441
1849
|
return count === 0 ? undefined : routes;
|
|
@@ -2471,11 +1879,11 @@ export class Server {
|
|
|
2471
1879
|
// `server` 2nd arg is how Bun exposes `upgrade`); otherwise the lean request-only fetch. The
|
|
2472
1880
|
// `websocket` handlers are one shared dispatcher — each connection's `ws.data.handler` is the
|
|
2473
1881
|
// matched route's handler, set by `server.upgrade`.
|
|
2474
|
-
// With WS routes, the dispatcher comes from the
|
|
2475
|
-
// wsRouteCount > 0 means ws() ran, and ws() requires the runtime at registration.
|
|
1882
|
+
// With WS routes, the dispatcher comes from the installed `.use(websocket())` runtime — non-null
|
|
1883
|
+
// because wsRouteCount > 0 means ws() ran, and ws() requires the runtime at registration.
|
|
2476
1884
|
const wsHandlers = this.wsRouteCount === 0
|
|
2477
1885
|
? undefined
|
|
2478
|
-
:
|
|
1886
|
+
: this.wsRuntime.bunHandlers(this.topics);
|
|
2479
1887
|
const reusePort = options?.reusePort === true;
|
|
2480
1888
|
// Spread rather than pass `hostname: undefined` - Bun treats an explicit undefined as a value
|
|
2481
1889
|
// on some option paths, and omitting is what selects its 0.0.0.0 default.
|
|
@@ -2487,7 +1895,7 @@ export class Server {
|
|
|
2487
1895
|
reusePort,
|
|
2488
1896
|
...bind,
|
|
2489
1897
|
...(nativeRoutes === undefined ? {} : { routes: nativeRoutes }),
|
|
2490
|
-
fetch: (req) => this.fetch(req),
|
|
1898
|
+
fetch: (req, server) => this.fetch(req, bunPeerPlatform(server, req)),
|
|
2491
1899
|
})
|
|
2492
1900
|
: Bun.serve({
|
|
2493
1901
|
port,
|
|
@@ -2507,6 +1915,7 @@ export class Server {
|
|
|
2507
1915
|
},
|
|
2508
1916
|
}));
|
|
2509
1917
|
this.bunServer = running;
|
|
1918
|
+
this.sealed = true;
|
|
2510
1919
|
if (this.gracefulSignals)
|
|
2511
1920
|
this.installSignalHandlers();
|
|
2512
1921
|
return running;
|
|
@@ -2585,7 +1994,9 @@ export function toFetchHandler(app, options) {
|
|
|
2585
1994
|
const pair = new Pair();
|
|
2586
1995
|
const server = pair[1];
|
|
2587
1996
|
server.accept();
|
|
2588
|
-
|
|
1997
|
+
// The outcome carries the installed runtime's `attach`, so `toFetchHandler` (a standalone
|
|
1998
|
+
// function, no access to the app's private runtime) wires the socket without a static WS import.
|
|
1999
|
+
outcome.attach(server, outcome.handler, outcome.data, {
|
|
2589
2000
|
openNow: true,
|
|
2590
2001
|
pubsub: outcome.pubsub,
|
|
2591
2002
|
});
|