@nifrajs/core 1.13.0 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +37 -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/capabilities.d.ts +54 -2
- package/dist/capabilities.d.ts.map +1 -1
- package/dist/capabilities.js +240 -5
- package/dist/capabilities.js.map +1 -1
- package/dist/durable-adapters.d.ts +241 -0
- package/dist/durable-adapters.d.ts.map +1 -0
- package/dist/durable-adapters.js +697 -0
- package/dist/durable-adapters.js.map +1 -0
- package/dist/durable-execution.d.ts +349 -0
- package/dist/durable-execution.d.ts.map +1 -0
- package/dist/durable-execution.js +1173 -0
- package/dist/durable-execution.js.map +1 -0
- 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/effect-lifecycle.d.ts +42 -0
- package/dist/effect-lifecycle.d.ts.map +1 -0
- package/dist/effect-lifecycle.js +66 -0
- package/dist/effect-lifecycle.js.map +1 -0
- package/dist/effect-scope.d.ts +75 -0
- package/dist/effect-scope.d.ts.map +1 -0
- package/dist/effect-scope.js +177 -0
- package/dist/effect-scope.js.map +1 -0
- package/dist/idempotency-plugin.d.ts +13 -0
- package/dist/idempotency-plugin.d.ts.map +1 -0
- package/dist/idempotency-plugin.js +16 -0
- package/dist/idempotency-plugin.js.map +1 -0
- package/dist/index.d.ts +6 -35
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -31
- package/dist/index.js.map +1 -1
- package/dist/internal/capability-runtime.d.ts +62 -1
- package/dist/internal/capability-runtime.d.ts.map +1 -1
- package/dist/internal/capability-runtime.js +11 -2
- package/dist/internal/capability-runtime.js.map +1 -1
- package/dist/internal/effect-execution.d.ts +16 -0
- package/dist/internal/effect-execution.d.ts.map +1 -0
- package/dist/internal/effect-execution.js +50 -0
- package/dist/internal/effect-execution.js.map +1 -0
- package/dist/internal/safe-equal.d.ts +20 -0
- package/dist/internal/safe-equal.d.ts.map +1 -0
- package/dist/internal/safe-equal.js +31 -0
- package/dist/internal/safe-equal.js.map +1 -0
- package/dist/ledger.d.ts +14 -6
- package/dist/ledger.d.ts.map +1 -1
- package/dist/ledger.js +31 -5
- package/dist/ledger.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/reconciliation-worker.d.ts +108 -0
- package/dist/reconciliation-worker.d.ts.map +1 -0
- package/dist/reconciliation-worker.js +239 -0
- package/dist/reconciliation-worker.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/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 +14 -4
- package/dist/server/contract.d.ts.map +1 -1
- package/dist/server/contract.js +8 -2
- 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 +45 -0
- package/dist/server/idempotency-lane.d.ts.map +1 -0
- package/dist/server/idempotency-lane.js +205 -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 +119 -345
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +247 -833
- 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/transport-lane.d.ts +28 -0
- package/dist/server/transport-lane.d.ts.map +1 -0
- package/dist/server/transport-lane.js +106 -0
- package/dist/server/transport-lane.js.map +1 -0
- package/dist/server/websocket.d.ts +23 -1
- package/dist/server/websocket.d.ts.map +1 -1
- package/dist/server/websocket.js +10 -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/transport-codec-rich.d.ts +8 -0
- package/dist/transport-codec-rich.d.ts.map +1 -0
- package/dist/transport-codec-rich.js +14 -0
- package/dist/transport-codec-rich.js.map +1 -0
- package/dist/transport-codec.d.ts +31 -0
- package/dist/transport-codec.d.ts.map +1 -0
- package/dist/transport-codec.js +190 -0
- package/dist/transport-codec.js.map +1 -0
- package/dist/transport-plugin.d.ts +3 -0
- package/dist/transport-plugin.d.ts.map +1 -0
- package/dist/transport-plugin.js +3 -0
- package/dist/transport-plugin.js.map +1 -0
- package/dist/wire.d.ts +58 -0
- package/dist/wire.d.ts.map +1 -0
- package/dist/wire.js +445 -0
- package/dist/wire.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 +122 -6
- package/src/assurance.ts +36 -1
- package/src/capabilities.ts +306 -5
- package/src/durable-adapters.ts +1039 -0
- package/src/durable-execution.ts +1676 -0
- package/src/effect-ledger.ts +9 -0
- package/src/effect-lifecycle.ts +120 -0
- package/src/effect-scope.ts +267 -0
- package/src/idempotency-plugin.ts +17 -0
- package/src/index.ts +6 -133
- package/src/internal/capability-runtime.ts +87 -1
- package/src/internal/effect-execution.ts +70 -0
- package/src/internal/safe-equal.ts +27 -0
- package/src/ledger.ts +45 -11
- package/src/mcp.ts +65 -0
- package/src/node-direct.ts +37 -0
- package/src/reconciliation-worker.ts +372 -0
- package/src/reflection.ts +7 -0
- package/src/server/client-ip.ts +60 -0
- package/src/server/context.ts +50 -2
- package/src/server/contract.ts +26 -7
- package/src/server/http.ts +58 -0
- package/src/server/idempotency-lane.ts +321 -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 +94 -0
- package/src/server/runtime-core.ts +74 -0
- package/src/server/server-types.ts +204 -0
- package/src/server/server.ts +448 -1314
- package/src/server/sse-hook.ts +14 -0
- package/src/server/sse.ts +26 -0
- package/src/server/transport-lane.ts +141 -0
- package/src/server/websocket.ts +35 -1
- package/src/server/ws-hook.ts +8 -19
- package/src/server.ts +92 -0
- package/src/transport-codec-rich.ts +20 -0
- package/src/transport-codec.ts +250 -0
- package/src/transport-plugin.ts +2 -0
- package/src/wire.ts +474 -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,41 +1,20 @@
|
|
|
1
1
|
import { admitDeadline, createRequestBudget, createUnboundedRequestBudget, NIFRA_DEADLINE_HEADER, } from "../budget.js";
|
|
2
2
|
import { FrameworkError, RouteConfigError } from "../errors.js";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import { assuranceDeclarationsOf, assuranceEvidenceFor, NIFRA_ASSURANCE_IDS, } from "../internal/route-assurance.js";
|
|
6
|
-
import { attachEffectLedger, createRequestLedger, DEFAULT_MAX_ENTRIES, } from "../ledger.js";
|
|
3
|
+
import { CAPABILITY_GUARD, createCapabilityGuard, DEFAULT_CAPABILITY_INTERCEPTOR_TIMEOUT_MS, normalizeRouteCapabilities, } from "../internal/capability-runtime.js";
|
|
4
|
+
import { assuranceDeclarationsOf, assuranceEvidenceFor, NIFRA_ASSURANCE_IDS, validEvidenceId, } from "../internal/route-assurance.js";
|
|
7
5
|
import { compileRoutePattern, decodeRouteParams, } from "../router/pattern.js";
|
|
8
6
|
import { EMPTY_PARAMS, Router } from "../router/router.js";
|
|
9
|
-
import {
|
|
10
|
-
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";
|
|
11
15
|
import { jsonLogger } from "./logger.js";
|
|
12
|
-
import { sse, typedSSEStream } from "./sse.js";
|
|
13
|
-
import { getWsRuntime } from "./ws-hook.js";
|
|
14
|
-
/** The concrete `Request` for a source — itself when a real `Request` was passed (the Web path), or the
|
|
15
|
-
* lazily-built one (the Node adapter). A real `Request` IS a `RequestSource`, so no wrapper is allocated
|
|
16
|
-
* on the Web hot path. */
|
|
17
|
-
function requestOf(source) {
|
|
18
|
-
return source.request ?? source;
|
|
19
|
-
}
|
|
20
|
-
function headerOf(source, name) {
|
|
21
|
-
return source.header?.(name) ?? source.headers.get(name);
|
|
22
|
-
}
|
|
23
|
-
const RESPONSE_RESULT = Symbol.for("nifra.response.result");
|
|
24
|
-
const CONTEXT_SET = Symbol("nifra.context.set");
|
|
25
|
-
const CONTEXT_SEARCH = Symbol("nifra.context.search");
|
|
26
16
|
const functionToString = Function.prototype.toString;
|
|
27
17
|
const CONTEXTLESS_ARROW = /^(?:async\s*)?\(\s*\)\s*(?::[\s\S]*?)?=>/;
|
|
28
|
-
function isResponseResult(value) {
|
|
29
|
-
return (typeof value === "object" &&
|
|
30
|
-
value !== null &&
|
|
31
|
-
value[RESPONSE_RESULT] === true &&
|
|
32
|
-
typeof value.toResponse === "function");
|
|
33
|
-
}
|
|
34
|
-
/** Off-edge `waitUntil`: run the background work fire-and-forget, never leaking an unhandled
|
|
35
|
-
* rejection. Edge runtimes pass their own (Workers `ctx.waitUntil`) via the platform arg. */
|
|
36
|
-
const fallbackWaitUntil = (promise) => {
|
|
37
|
-
void promise.catch(() => { });
|
|
38
|
-
};
|
|
39
18
|
/**
|
|
40
19
|
* Runtime route catalog. Single-route registration mutates directly; multi-route registration replays
|
|
41
20
|
* the existing catalog plus the candidate batch into a staged router, then swaps the complete state only
|
|
@@ -87,94 +66,38 @@ class RouteCatalog {
|
|
|
87
66
|
return this.assurancePresent;
|
|
88
67
|
}
|
|
89
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 };
|
|
74
|
+
}
|
|
90
75
|
const WS_PASS = { kind: "pass" };
|
|
91
76
|
/** `app.ws()` (and everything downstream of it) needs the runtime `@nifrajs/core/ws` registers. */
|
|
92
|
-
function requireWsRuntime() {
|
|
93
|
-
const runtime = getWsRuntime();
|
|
77
|
+
function requireWsRuntime(runtime) {
|
|
94
78
|
if (runtime === undefined) {
|
|
95
|
-
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.");
|
|
96
80
|
}
|
|
97
81
|
return runtime;
|
|
98
82
|
}
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
* ```ts
|
|
105
|
-
* export const requestId = definePlugin("requestId", (app) => app.derive(() => ({ requestId: uuid() })))
|
|
106
|
-
* app.use(requestId) // downstream handlers see c.requestId
|
|
107
|
-
* ```
|
|
108
|
-
*
|
|
109
|
-
* FOOTGUN: only use this for a plugin that adds context. For a plugin that **mounts routes/hooks but adds
|
|
110
|
-
* NO context** (an auth router, an audit logger), use {@link defineRouterPlugin} ({@link defineIdentityPlugin}).
|
|
111
|
-
* `definePlugin((app) => app.get(...))` infers `app: Server<any, any>`, so `.use()` returns `Server<any, any>`
|
|
112
|
-
* and your whole typed client silently collapses to `any` — no type error, no runtime error.
|
|
113
|
-
*/
|
|
114
|
-
export function definePlugin(name, apply) {
|
|
115
|
-
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;
|
|
116
88
|
}
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
*
|
|
123
|
-
* Why a dedicated helper: `definePlugin((app) => app)` infers `app: Server<any, any>`, so `use` returns
|
|
124
|
-
* `Server<any, any>` and the whole typed client collapses to `any`. The explicit generic return type here
|
|
125
|
-
* (which a plain `Object.assign` can't preserve) is what keeps `use` returning the precise server type.
|
|
126
|
-
*
|
|
127
|
-
* ```ts
|
|
128
|
-
* export const audit = defineIdentityPlugin("audit", (app) => app.onResponse(logResponse))
|
|
129
|
-
* const api = server().get("/a", h).use(audit).get("/b", h) // /a AND /b stay typed
|
|
130
|
-
* ```
|
|
131
|
-
*/
|
|
132
|
-
export function defineIdentityPlugin(name, apply) {
|
|
133
|
-
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;
|
|
134
94
|
}
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
* {@link definePlugin} — for any such plugin, or the typed client silently collapses to `any`. The
|
|
139
|
-
* "identity" in {@link defineIdentityPlugin} refers to the type-identity it preserves; `defineRouterPlugin`
|
|
140
|
-
* is the same thing under a clearer name.
|
|
141
|
-
*
|
|
142
|
-
* Mount routes as a **side effect**, then return the app unchanged (registering via `.get`/`.post` would
|
|
143
|
-
* change the type away from the identity `S`; the mounted routes run but aren't in the caller's typed
|
|
144
|
-
* registry — that's the trade that keeps everything else typed):
|
|
145
|
-
*
|
|
146
|
-
* ```ts
|
|
147
|
-
* export const scim = defineRouterPlugin("scim", (app) => {
|
|
148
|
-
* app.get("/scim/v2/Users", listUsers) // side effect: mounted at runtime
|
|
149
|
-
* return app // return S unchanged → routes added after .use(scim) stay typed
|
|
150
|
-
* })
|
|
151
|
-
* const api = server().get("/a", h).use(scim).get("/b", h) // /a AND /b stay typed
|
|
152
|
-
* ```
|
|
153
|
-
*/
|
|
154
|
-
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";
|
|
155
98
|
const DEFAULT_MAX_BODY_BYTES = 1_000_000;
|
|
156
99
|
const DEFAULT_DRAIN_MS = 10_000;
|
|
157
100
|
const DRAIN_POLL_MS = 10;
|
|
158
|
-
const TEXT_DECODER = new TextDecoder();
|
|
159
|
-
/**
|
|
160
|
-
* Shared never-aborting signal for `ctx.signal` when no timeout is armed — created lazily and
|
|
161
|
-
* cached. NOT a module-scope `new AbortController()`: edge runtimes (Cloudflare workerd) forbid
|
|
162
|
-
* constructing one in global scope; the first request builds it inside the handler, then it's
|
|
163
|
-
* reused at zero per-request cost.
|
|
164
|
-
*/
|
|
165
|
-
let neverAbortSignal;
|
|
166
|
-
let unboundedRequestBudget;
|
|
167
|
-
const getNeverAbortSignal = () => {
|
|
168
|
-
neverAbortSignal ??= new AbortController().signal;
|
|
169
|
-
return neverAbortSignal;
|
|
170
|
-
};
|
|
171
|
-
const getUnboundedRequestBudget = () => {
|
|
172
|
-
unboundedRequestBudget ??= createUnboundedRequestBudget(getNeverAbortSignal());
|
|
173
|
-
return unboundedRequestBudget;
|
|
174
|
-
};
|
|
175
|
-
function jsonError(status, error, headers) {
|
|
176
|
-
return Response.json({ ok: false, error }, headers !== undefined ? { status, headers } : { status });
|
|
177
|
-
}
|
|
178
101
|
/** Same-origin check for a WebSocket handshake (CSWSH default): the `Origin`'s host[:port] must equal the
|
|
179
102
|
* request's own host (from `req.url`, which the runtime builds from the `Host` header). Scheme differs
|
|
180
103
|
* (ws↔http), so compare host only. An unparseable Origin is treated as NOT same-origin (rejected). */
|
|
@@ -193,135 +116,12 @@ function validationError(issues) {
|
|
|
193
116
|
});
|
|
194
117
|
return Response.json({ ok: false, error: "validation", issues: serialized }, { status: 422 });
|
|
195
118
|
}
|
|
196
|
-
//
|
|
197
|
-
//
|
|
198
|
-
|
|
199
|
-
// string
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
const start = schemeEnd === -1 ? url.indexOf("/") : url.indexOf("/", schemeEnd + 3);
|
|
203
|
-
if (start === -1)
|
|
204
|
-
return { pathname: "/", search: "" };
|
|
205
|
-
let pathEnd = url.length;
|
|
206
|
-
let searchStart = -1;
|
|
207
|
-
let searchEnd = url.length;
|
|
208
|
-
for (let i = start; i < url.length; i++) {
|
|
209
|
-
const c = url.charCodeAt(i);
|
|
210
|
-
if (c === 63 /* ? */ && searchStart === -1) {
|
|
211
|
-
pathEnd = i;
|
|
212
|
-
searchStart = i;
|
|
213
|
-
}
|
|
214
|
-
else if (c === 35 /* # */) {
|
|
215
|
-
if (searchStart === -1)
|
|
216
|
-
pathEnd = i;
|
|
217
|
-
searchEnd = i;
|
|
218
|
-
break;
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
|
-
return {
|
|
222
|
-
pathname: url.slice(start, pathEnd),
|
|
223
|
-
search: searchStart === -1 ? "" : url.slice(searchStart, searchEnd),
|
|
224
|
-
};
|
|
225
|
-
}
|
|
226
|
-
// Extract the pathname WITHOUT a full WHATWG `new URL(req.url)` parse. Kept as a public-ish helper
|
|
227
|
-
// for tests and callers that only need the path; the request hot path uses `urlPartsOf()` once.
|
|
228
|
-
export function pathnameOf(url) {
|
|
229
|
-
return urlPartsOf(url).pathname;
|
|
230
|
-
}
|
|
231
|
-
// The query string ("?a=1", or "" when absent) — for lazily building `c.query` only when read.
|
|
232
|
-
// The fragment (after the first `#`) bounds the search: a `?` that appears only inside the fragment
|
|
233
|
-
// is NOT a query (matches WHATWG). Fragments never reach the server in `req.url`, so this is purely
|
|
234
|
-
// for provable equivalence with `new URL(req.url).search`.
|
|
235
|
-
export function searchOf(url) {
|
|
236
|
-
return urlPartsOf(url).search;
|
|
237
|
-
}
|
|
238
|
-
/** Accumulate into a NULL-PROTOTYPE record (every call site below creates one): with no inherited
|
|
239
|
-
* `constructor`/`toString`/`__proto__` accessors, a hostile key is just an own data key — the
|
|
240
|
-
* promotion logic can't collide with `Object.prototype` members, and `__proto__` needs no special
|
|
241
|
-
* case (direct assignment on a null-proto object creates an own property). */
|
|
242
|
-
function setQueryValue(out, key, value) {
|
|
243
|
-
const existing = out[key];
|
|
244
|
-
if (existing === undefined) {
|
|
245
|
-
out[key] = value;
|
|
246
|
-
}
|
|
247
|
-
else if (typeof existing === "string") {
|
|
248
|
-
out[key] = [existing, value];
|
|
249
|
-
}
|
|
250
|
-
else {
|
|
251
|
-
existing.push(value);
|
|
252
|
-
}
|
|
253
|
-
}
|
|
254
|
-
function queryObjectFallback(search) {
|
|
255
|
-
// Manual iteration instead of Object.fromEntries: repeated keys must promote to arrays
|
|
256
|
-
// (fromEntries is last-wins), and __proto__ needs the same own-property guard.
|
|
257
|
-
const out = Object.create(null);
|
|
258
|
-
for (const [key, value] of new URLSearchParams(search)) {
|
|
259
|
-
setQueryValue(out, key, value);
|
|
260
|
-
}
|
|
261
|
-
return out;
|
|
262
|
-
}
|
|
263
|
-
/**
|
|
264
|
-
* Build the plain object passed to query schemas. Plain ASCII-ish queries avoid
|
|
265
|
-
* `URLSearchParams` + iterator allocations; encoded queries fall back to the Web API so `+`,
|
|
266
|
-
* percent-decoding, malformed escapes, and empty-key behavior stay exact. Repeated keys promote
|
|
267
|
-
* to `string[]` on BOTH paths (see {@link setQueryValue}).
|
|
268
|
-
*/
|
|
269
|
-
// Shared empty result for no-query requests: frozen + null-prototype (same shape contract as the
|
|
270
|
-
// populated path), allocated once instead of per request.
|
|
271
|
-
const EMPTY_QUERY = Object.freeze(Object.create(null));
|
|
272
|
-
export function queryObjectOf(search) {
|
|
273
|
-
const start = search.charCodeAt(0) === 63 /* ? */ ? 1 : 0;
|
|
274
|
-
if (start >= search.length)
|
|
275
|
-
return EMPTY_QUERY;
|
|
276
|
-
for (let i = start; i < search.length; i++) {
|
|
277
|
-
const c = search.charCodeAt(i);
|
|
278
|
-
if (c === 37 /* % */ || c === 43 /* + */)
|
|
279
|
-
return queryObjectFallback(search);
|
|
280
|
-
}
|
|
281
|
-
const out = Object.create(null);
|
|
282
|
-
let pos = start;
|
|
283
|
-
while (pos <= search.length) {
|
|
284
|
-
const amp = search.indexOf("&", pos);
|
|
285
|
-
const end = amp === -1 ? search.length : amp;
|
|
286
|
-
if (end > pos) {
|
|
287
|
-
const eq = search.indexOf("=", pos);
|
|
288
|
-
const split = eq !== -1 && eq < end ? eq : end;
|
|
289
|
-
const key = search.slice(pos, split);
|
|
290
|
-
const value = split === end ? "" : search.slice(split + 1, end);
|
|
291
|
-
setQueryValue(out, key, value);
|
|
292
|
-
}
|
|
293
|
-
if (amp === -1)
|
|
294
|
-
break;
|
|
295
|
-
pos = amp + 1;
|
|
296
|
-
}
|
|
297
|
-
return out;
|
|
298
|
-
}
|
|
299
|
-
/** `application/x-www-form-urlencoded`, with or without a charset suffix. */
|
|
300
|
-
function isUrlEncodedForm(contentType) {
|
|
301
|
-
return (contentType === "application/x-www-form-urlencoded" ||
|
|
302
|
-
contentType.startsWith("application/x-www-form-urlencoded;"));
|
|
303
|
-
}
|
|
304
|
-
const FORM_DECODER = new TextDecoder();
|
|
305
|
-
/**
|
|
306
|
-
* Read an HTML-form body (urlencoded) into the plain object a body schema validates — same byte
|
|
307
|
-
* cap as the JSON path (a lying/absent Content-Length can't force an oversized buffer), same
|
|
308
|
-
* repeated-key → `string[]` promotion as query parsing, same `__proto__` guard.
|
|
309
|
-
*/
|
|
310
|
-
async function readBoundedForm(req, maxBytes) {
|
|
311
|
-
const read = await readBoundedBytes(req, maxBytes);
|
|
312
|
-
if (!read.ok) {
|
|
313
|
-
return read.status === 413
|
|
314
|
-
? jsonError(413, "payload_too_large")
|
|
315
|
-
: jsonError(400, "invalid_content_length");
|
|
316
|
-
}
|
|
317
|
-
const out = Object.create(null);
|
|
318
|
-
// URLSearchParams owns the format's quirks (`+` as space, percent-decoding, empty keys);
|
|
319
|
-
// it never throws on junk input, so no try/catch is needed here.
|
|
320
|
-
for (const [key, value] of new URLSearchParams(FORM_DECODER.decode(read.bytes))) {
|
|
321
|
-
setQueryValue(out, key, value);
|
|
322
|
-
}
|
|
323
|
-
return out;
|
|
324
|
-
}
|
|
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 };
|
|
325
125
|
function hasReplacementParam(params) {
|
|
326
126
|
for (const key in params) {
|
|
327
127
|
if (params[key].includes("\uFFFD"))
|
|
@@ -329,35 +129,6 @@ function hasReplacementParam(params) {
|
|
|
329
129
|
}
|
|
330
130
|
return false;
|
|
331
131
|
}
|
|
332
|
-
class LazyResponseControls {
|
|
333
|
-
status;
|
|
334
|
-
_headers;
|
|
335
|
-
_cookies;
|
|
336
|
-
get headers() {
|
|
337
|
-
this._headers ??= {};
|
|
338
|
-
return this._headers;
|
|
339
|
-
}
|
|
340
|
-
cookie(name, value, options) {
|
|
341
|
-
// Secure-by-default: HttpOnly + Secure + SameSite=Lax + Path=/, overridable per call.
|
|
342
|
-
const merged = {
|
|
343
|
-
httpOnly: true,
|
|
344
|
-
secure: true,
|
|
345
|
-
sameSite: "lax",
|
|
346
|
-
path: "/",
|
|
347
|
-
...options,
|
|
348
|
-
};
|
|
349
|
-
this._cookies ??= [];
|
|
350
|
-
this._cookies.push(serializeCookie(name, value, merged));
|
|
351
|
-
}
|
|
352
|
-
deleteCookie(name, options) {
|
|
353
|
-
// Expire immediately; default Path=/, and match the original path/domain or the browser keeps it.
|
|
354
|
-
this._cookies ??= [];
|
|
355
|
-
this._cookies.push(serializeCookie(name, "", { path: "/", ...options, maxAge: 0, expires: EPOCH }));
|
|
356
|
-
}
|
|
357
|
-
}
|
|
358
|
-
// Finalization only reads `status`, `_headers`, and `_cookies`. The user-visible mutator methods
|
|
359
|
-
// live on `LazyResponseControls`, created by the `c.set` getter only when user code touches it.
|
|
360
|
-
const EMPTY_RESPONSE_CONTROLS = Object.freeze({});
|
|
361
132
|
function responseSet(ctx) {
|
|
362
133
|
return ctx[CONTEXT_SET]() ?? EMPTY_RESPONSE_CONTROLS;
|
|
363
134
|
}
|
|
@@ -371,302 +142,9 @@ function isContextlessNoArgArrow(handler) {
|
|
|
371
142
|
return false;
|
|
372
143
|
}
|
|
373
144
|
}
|
|
374
|
-
/** Coerce `c.json`/`c.text`'s second arg — a status number (the common case) or a full `ResponseInit`. */
|
|
375
|
-
function statusInit(init) {
|
|
376
|
-
return typeof init === "number" ? { status: init } : init;
|
|
377
|
-
}
|
|
378
|
-
class RequestContext {
|
|
379
|
-
constructor(source, params, searchOrMaxBodyBytes, signal, budget, platform, maxBodyBytes) {
|
|
380
|
-
this.source = source;
|
|
381
|
-
this.params = params;
|
|
382
|
-
if (typeof searchOrMaxBodyBytes === "number") {
|
|
383
|
-
this.maxBodyBytes = searchOrMaxBodyBytes;
|
|
384
|
-
return;
|
|
385
|
-
}
|
|
386
|
-
if (searchOrMaxBodyBytes !== undefined)
|
|
387
|
-
this.searchValue = searchOrMaxBodyBytes;
|
|
388
|
-
this.signalValue = signal;
|
|
389
|
-
this.budgetValue = budget;
|
|
390
|
-
if (platform !== undefined)
|
|
391
|
-
this.platformValue = platform;
|
|
392
|
-
this.maxBodyBytes = maxBodyBytes;
|
|
393
|
-
}
|
|
394
|
-
static native(source, params, maxBodyBytes) {
|
|
395
|
-
return new RequestContext(source, params, maxBodyBytes);
|
|
396
|
-
}
|
|
397
|
-
[CONTEXT_SET]() {
|
|
398
|
-
return this.setValue;
|
|
399
|
-
}
|
|
400
|
-
get [CONTEXT_SEARCH]() {
|
|
401
|
-
this.searchValue ??= searchOf(this.source.url);
|
|
402
|
-
return this.searchValue;
|
|
403
|
-
}
|
|
404
|
-
get set() {
|
|
405
|
-
this.setValue ??= new LazyResponseControls();
|
|
406
|
-
return this.setValue;
|
|
407
|
-
}
|
|
408
|
-
get signal() {
|
|
409
|
-
return this.signalValue ?? getNeverAbortSignal();
|
|
410
|
-
}
|
|
411
|
-
get budget() {
|
|
412
|
-
return this.budgetValue ?? getUnboundedRequestBudget();
|
|
413
|
-
}
|
|
414
|
-
get env() {
|
|
415
|
-
return this.platformValue?.env;
|
|
416
|
-
}
|
|
417
|
-
get waitUntil() {
|
|
418
|
-
return this.platformValue?.waitUntil ?? fallbackWaitUntil;
|
|
419
|
-
}
|
|
420
|
-
get req() {
|
|
421
|
-
return requestOf(this.source);
|
|
422
|
-
}
|
|
423
|
-
get request() {
|
|
424
|
-
return requestOf(this.source);
|
|
425
|
-
}
|
|
426
|
-
json(body, init) {
|
|
427
|
-
return Response.json(body, statusInit(init));
|
|
428
|
-
}
|
|
429
|
-
text(body, init) {
|
|
430
|
-
const i = statusInit(init);
|
|
431
|
-
// Default to text/plain; if the caller passes their own headers, they own the content-type.
|
|
432
|
-
if (i?.headers !== undefined)
|
|
433
|
-
return new Response(body, i);
|
|
434
|
-
return new Response(body, { ...i, headers: { "content-type": "text/plain; charset=utf-8" } });
|
|
435
|
-
}
|
|
436
|
-
get query() {
|
|
437
|
-
if (!this.queryReady) {
|
|
438
|
-
this.queryValue = new URLSearchParams(this[CONTEXT_SEARCH]);
|
|
439
|
-
this.queryReady = true;
|
|
440
|
-
}
|
|
441
|
-
return this.queryValue;
|
|
442
|
-
}
|
|
443
|
-
set query(v) {
|
|
444
|
-
this.queryValue = v;
|
|
445
|
-
this.queryReady = true;
|
|
446
|
-
}
|
|
447
|
-
get cookies() {
|
|
448
|
-
this.cookiesValue ??= parseCookies(headerOf(this.source, "cookie"));
|
|
449
|
-
return this.cookiesValue;
|
|
450
|
-
}
|
|
451
|
-
boundedBody(maxBytes) {
|
|
452
|
-
return readBoundedBodyOrThrow(this.source, this.maxBodyBytes, maxBytes);
|
|
453
|
-
}
|
|
454
|
-
boundedJson(maxBytes) {
|
|
455
|
-
return readBoundedJsonBodyOrThrow(this.source, this.maxBodyBytes, maxBytes);
|
|
456
|
-
}
|
|
457
|
-
}
|
|
458
|
-
/** Backs `c.boundedBody`: bounded byte read that throws a flat 413/400 `Response` (caught by
|
|
459
|
-
* `runLifecycle` as control flow, like `throw redirect(...)`), so a handler can't ignore the cap.
|
|
460
|
-
* The byte-cap itself lives in `./body.ts` (shared with the schema path and `verifyWebhook`). */
|
|
461
|
-
async function readBoundedBodyOrThrow(req, maxBodyBytes, maxBytes) {
|
|
462
|
-
const r = await readBoundedBytes(req, maxBytes ?? maxBodyBytes);
|
|
463
|
-
if (r.ok)
|
|
464
|
-
return r.bytes;
|
|
465
|
-
throw r.status === 413
|
|
466
|
-
? jsonError(413, "payload_too_large")
|
|
467
|
-
: jsonError(400, "invalid_content_length");
|
|
468
|
-
}
|
|
469
|
-
/** Backs `c.boundedJson`: `readBoundedBodyOrThrow` + `JSON.parse`, throwing a flat 400 on bad JSON. */
|
|
470
|
-
async function readBoundedJsonBodyOrThrow(req, maxBodyBytes, maxBytes) {
|
|
471
|
-
const parsed = await readBoundedJsonSource(req, maxBytes ?? maxBodyBytes);
|
|
472
|
-
if (parsed instanceof Response)
|
|
473
|
-
throw parsed;
|
|
474
|
-
return parsed;
|
|
475
|
-
}
|
|
476
|
-
/**
|
|
477
|
-
* Read a JSON body with the same byte cap used by schema validation and `c.boundedJson`.
|
|
478
|
-
* Non-chunked, framed requests with an in-cap `Content-Length` use the runtime-native `req.json()`;
|
|
479
|
-
* chunked or length-less requests fall back to the streaming byte-cap guard.
|
|
480
|
-
*/
|
|
481
|
-
async function readBoundedJsonSource(req, maxBytes) {
|
|
482
|
-
const declared = headerOf(req, "content-length");
|
|
483
|
-
if (declared !== null) {
|
|
484
|
-
// A present Content-Length must be a non-negative integer (HTTP grammar: `1*DIGIT`). A
|
|
485
|
-
// non-numeric / negative / fractional / exponential value (`Number()` would happily accept
|
|
486
|
-
// "abc"→NaN, "-5", "1.5", "1e3", "0x10") is malformed → 400, rather than silently falling
|
|
487
|
-
// through to the streaming guard — which is an UPPER-bound cap only, so a lying SMALLER length
|
|
488
|
-
// would otherwise be read in full. Real HTTP servers only hand us a valid framed length; this
|
|
489
|
-
// hardens hand-built Requests (tests, the in-process client) and crafted input.
|
|
490
|
-
const length = parseContentLength(declared);
|
|
491
|
-
if (length === undefined)
|
|
492
|
-
return jsonError(400, "invalid_content_length");
|
|
493
|
-
if (length > maxBytes)
|
|
494
|
-
return jsonError(413, "payload_too_large");
|
|
495
|
-
const chunked = headerOf(req, "transfer-encoding") !== null;
|
|
496
|
-
if (!chunked) {
|
|
497
|
-
try {
|
|
498
|
-
return await req.json();
|
|
499
|
-
}
|
|
500
|
-
catch {
|
|
501
|
-
return jsonError(400, "invalid_json");
|
|
502
|
-
}
|
|
503
|
-
}
|
|
504
|
-
}
|
|
505
|
-
const body = req.body;
|
|
506
|
-
if (body === null)
|
|
507
|
-
return jsonError(400, "invalid_json");
|
|
508
|
-
const drained = await drainCapped(body, maxBytes);
|
|
509
|
-
if (!drained.ok)
|
|
510
|
-
return jsonError(413, "payload_too_large");
|
|
511
|
-
try {
|
|
512
|
-
return JSON.parse(TEXT_DECODER.decode(drained.bytes));
|
|
513
|
-
}
|
|
514
|
-
catch {
|
|
515
|
-
return jsonError(400, "invalid_json");
|
|
516
|
-
}
|
|
517
|
-
}
|
|
518
|
-
/** A fixed past instant for cookie deletion (`Expires`). A literal epoch — deterministic, unlike an
|
|
519
|
-
* argless `new Date()`. */
|
|
520
|
-
const EPOCH = new Date(0);
|
|
521
|
-
/** Build the response headers init. The common path (no `c.set`) returns `undefined` so `Response`
|
|
522
|
-
* gets no `headers` at all. Cookies force a `Headers` object — multiple `Set-Cookie`s can't live in a
|
|
523
|
-
* `Record<string,string>` (the 2nd would overwrite the 1st), so they're `append`ed individually. */
|
|
524
|
-
function headersInit(set) {
|
|
525
|
-
const cookies = set._cookies;
|
|
526
|
-
if (cookies === undefined || cookies.length === 0)
|
|
527
|
-
return set._headers;
|
|
528
|
-
const headers = new Headers(set._headers);
|
|
529
|
-
for (const cookie of cookies)
|
|
530
|
-
headers.append("set-cookie", cookie);
|
|
531
|
-
return headers;
|
|
532
|
-
}
|
|
533
|
-
// Keep the fast JSON respond path byte-identical to `Response.json` without probing it at module
|
|
534
|
-
// scope: workerd forbids `Response.json()` during startup. A shared `Headers` is safe to reuse
|
|
535
|
-
// across responses: the Response constructor copies `init.headers` into its own list.
|
|
536
|
-
const JSON_CT_HEADERS = new Headers({
|
|
537
|
-
"content-type": "application/json;charset=utf-8",
|
|
538
|
-
});
|
|
539
|
-
const JSON_INIT_200 = { status: 200, headers: JSON_CT_HEADERS };
|
|
540
|
-
/** Fused-lane respond when `c.set` is untouched. Bun 1.3's native `Response.json` now beats the
|
|
541
|
-
* older hand-inlined stringify + Response construction on this lane while preserving the exact
|
|
542
|
-
* body/content-type contract; keep the generic fallback for non-JSON values. */
|
|
543
|
-
function fusedRespondNoSet(result) {
|
|
544
|
-
if (result !== undefined &&
|
|
545
|
-
!(result instanceof Response) &&
|
|
546
|
-
typeof result === "object" &&
|
|
547
|
-
result !== null &&
|
|
548
|
-
!isResponseResult(result)) {
|
|
549
|
-
return Response.json(result);
|
|
550
|
-
}
|
|
551
|
-
return toResponse(result, EMPTY_RESPONSE_CONTROLS);
|
|
552
|
-
}
|
|
553
|
-
/** Fused-lane respond with a context: read `c.set` once; untouched (the common case) → the fast
|
|
554
|
-
* JSON respond; touched → the generic `toResponse` with those controls (statuses, headers, cookies). */
|
|
555
|
-
function fusedRespond(result, ctx) {
|
|
556
|
-
const set = ctx[CONTEXT_SET]();
|
|
557
|
-
if (set === undefined)
|
|
558
|
-
return fusedRespondNoSet(result);
|
|
559
|
-
return toResponse(result, set);
|
|
560
|
-
}
|
|
561
|
-
function toResponse(result, set) {
|
|
562
|
-
if (isResponseResult(result)) {
|
|
563
|
-
return appendCookiesToResponse(result.toResponse(), set);
|
|
564
|
-
}
|
|
565
|
-
if (result instanceof Response) {
|
|
566
|
-
return appendCookiesToResponse(result, set);
|
|
567
|
-
}
|
|
568
|
-
const headers = headersInit(set);
|
|
569
|
-
const status = set.status ?? (result === undefined ? 204 : 200);
|
|
570
|
-
if (headers === undefined && result !== undefined) {
|
|
571
|
-
// Fast respond (profiled ≈ −50 ns/req on every plain-JSON return): `JSON.stringify` + a
|
|
572
|
-
// prebuilt init beats `Response.json`'s internal init handling. Output is byte-identical —
|
|
573
|
-
// same body bytes, same probed content-type. `undefined` from stringify (a function/symbol
|
|
574
|
-
// result) delegates to Response.json so its TypeError contract stays the single source.
|
|
575
|
-
const body = JSON.stringify(result);
|
|
576
|
-
if (body !== undefined) {
|
|
577
|
-
return new Response(body, status === 200 ? JSON_INIT_200 : { status, headers: JSON_CT_HEADERS });
|
|
578
|
-
}
|
|
579
|
-
}
|
|
580
|
-
const init = headers === undefined ? { status } : { status, headers };
|
|
581
|
-
return result === undefined ? new Response(null, init) : Response.json(result, init);
|
|
582
|
-
}
|
|
583
|
-
function appendCookiesToResponse(response, set) {
|
|
584
|
-
// A handler may queue cookies (`c.set.cookie` — e.g. a session cookie) AND return its own Response
|
|
585
|
-
// (e.g. `redirect("/")` after login). Cookies accumulate additively, so append them to the
|
|
586
|
-
// returned Response — otherwise the canonical set-session-then-redirect pattern would silently drop
|
|
587
|
-
// the cookie. (Other `c.set` fields stay the returned Response's own concern.)
|
|
588
|
-
const cookies = set._cookies;
|
|
589
|
-
if (cookies !== undefined && cookies.length > 0) {
|
|
590
|
-
for (const cookie of cookies)
|
|
591
|
-
response.headers.append("set-cookie", cookie);
|
|
592
|
-
}
|
|
593
|
-
return response;
|
|
594
|
-
}
|
|
595
|
-
/**
|
|
596
|
-
* `finalize` for the node-direct path — mirror of {@link toResponse} that skips the `Response` build:
|
|
597
|
-
* a plain value becomes pre-stringified JSON primitives (the adapter `JSON.stringify`s once, here, not
|
|
598
|
-
* via `Response.json` + a body drain); a handler-returned `Response` is wrapped as-is, with queued
|
|
599
|
-
* cookies appended exactly as `toResponse` does (so the set-cookie-then-`redirect()` pattern still
|
|
600
|
-
* works on Node).
|
|
601
|
-
*/
|
|
602
|
-
function toNodeOutcome(result, set) {
|
|
603
|
-
if (isResponseResult(result)) {
|
|
604
|
-
const body = result.toNodeBody?.();
|
|
605
|
-
if (body !== undefined) {
|
|
606
|
-
return {
|
|
607
|
-
kind: "body",
|
|
608
|
-
status: body.status,
|
|
609
|
-
headers: appendCookiesToNodeHeaders(body.headers, set._cookies),
|
|
610
|
-
body: body.body,
|
|
611
|
-
};
|
|
612
|
-
}
|
|
613
|
-
return nodeOutcomeFromResponse(appendCookiesToResponse(result.toResponse(), set));
|
|
614
|
-
}
|
|
615
|
-
if (result instanceof Response) {
|
|
616
|
-
return nodeOutcomeFromResponse(appendCookiesToResponse(result, set));
|
|
617
|
-
}
|
|
618
|
-
const status = set.status ?? (result === undefined ? 204 : 200);
|
|
619
|
-
return {
|
|
620
|
-
kind: "json",
|
|
621
|
-
status,
|
|
622
|
-
headers: set._headers,
|
|
623
|
-
cookies: set._cookies,
|
|
624
|
-
body: result === undefined ? null : JSON.stringify(result),
|
|
625
|
-
};
|
|
626
|
-
}
|
|
627
145
|
// Stable module-level finalizers so `fetch`/`resolveNode` allocate no per-request closures.
|
|
628
146
|
const IDENTITY_RESPONSE = (response) => response;
|
|
629
147
|
const RESPONSE_TIMEOUT = () => jsonError(503, "request_timeout");
|
|
630
|
-
const NODE_RESPONSE_BODY = Symbol.for("nifra.response.body");
|
|
631
|
-
const NODE_FROM_RESPONSE = (response) => nodeOutcomeFromResponse(response);
|
|
632
|
-
const NODE_TIMEOUT = () => ({
|
|
633
|
-
kind: "response",
|
|
634
|
-
response: jsonError(503, "request_timeout"),
|
|
635
|
-
});
|
|
636
|
-
function nodeOutcomeFromResponse(response) {
|
|
637
|
-
const body = nodeResponseBody(response);
|
|
638
|
-
return body === undefined
|
|
639
|
-
? { kind: "response", response }
|
|
640
|
-
: { kind: "body", status: response.status, headers: responseHeadersForNode(response), body };
|
|
641
|
-
}
|
|
642
|
-
function nodeResponseBody(response) {
|
|
643
|
-
if (response.bodyUsed)
|
|
644
|
-
return undefined;
|
|
645
|
-
const body = response[NODE_RESPONSE_BODY];
|
|
646
|
-
return typeof body === "string" || body instanceof Uint8Array ? body : undefined;
|
|
647
|
-
}
|
|
648
|
-
function responseHeadersForNode(response) {
|
|
649
|
-
let headers;
|
|
650
|
-
response.headers.forEach((value, key) => {
|
|
651
|
-
headers ??= {};
|
|
652
|
-
headers[key] = value;
|
|
653
|
-
});
|
|
654
|
-
const setCookies = response.headers.getSetCookie?.();
|
|
655
|
-
if (setCookies !== undefined && setCookies.length > 0) {
|
|
656
|
-
headers ??= {};
|
|
657
|
-
headers["set-cookie"] = setCookies;
|
|
658
|
-
}
|
|
659
|
-
return headers;
|
|
660
|
-
}
|
|
661
|
-
function appendCookiesToNodeHeaders(headers, cookies) {
|
|
662
|
-
if (cookies === undefined || cookies.length === 0)
|
|
663
|
-
return headers;
|
|
664
|
-
const out = headers === undefined ? {} : { ...headers };
|
|
665
|
-
const existing = out["set-cookie"];
|
|
666
|
-
const setCookies = existing === undefined ? [] : typeof existing === "string" ? [existing] : [...existing];
|
|
667
|
-
out["set-cookie"] = [...setCookies, ...cookies];
|
|
668
|
-
return out;
|
|
669
|
-
}
|
|
670
148
|
/**
|
|
671
149
|
* The inline server. Routes are chainable and fully type-inferred. `derive`/
|
|
672
150
|
* `decorate` extend the handler context (`Ctx`) for routes defined *after* them,
|
|
@@ -687,6 +165,8 @@ export class Server {
|
|
|
687
165
|
maxBodyBytes;
|
|
688
166
|
wsMaxPayloadBytes;
|
|
689
167
|
requestTimeoutMs;
|
|
168
|
+
/** Opt-in caller-IP trust declaration; `undefined` = socket peer only, no forwarded header believed. */
|
|
169
|
+
clientIpTrust;
|
|
690
170
|
acceptInboundDeadlines;
|
|
691
171
|
maxInboundDeadlineMs;
|
|
692
172
|
deadlineAdmissionOptions;
|
|
@@ -694,11 +174,22 @@ export class Server {
|
|
|
694
174
|
/** Capacity-admission gate; `undefined` = off (the request path pays nothing). */
|
|
695
175
|
capacityGate;
|
|
696
176
|
onCapabilityUse;
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
/**
|
|
700
|
-
|
|
701
|
-
|
|
177
|
+
capabilityInterceptors;
|
|
178
|
+
capabilityObservers;
|
|
179
|
+
/** The installed effect-ledger runtime (owns the sink + per-route resolution + settle), or
|
|
180
|
+
* `undefined` when the effect-ledger plugin is not installed. */
|
|
181
|
+
effectLedgerRuntime;
|
|
182
|
+
/** The installed idempotency runtime (owns the app-wide default store + the dedupe lane), or
|
|
183
|
+
* `undefined` when the idempotency plugin is not installed. */
|
|
184
|
+
idempotencyRuntime;
|
|
185
|
+
/** Installed opt-in runtime for `.tool()`/`.resource()`/`.prompt()`; `undefined` until `.use(mcp())`. */
|
|
186
|
+
mcpRuntime;
|
|
187
|
+
/** Installed Node-direct renderer for direct `resolveNode()` callers; `undefined` until `.use(nodeDirect())`. */
|
|
188
|
+
nodeOutcomeRuntime;
|
|
189
|
+
/** Installed streaming runtime for `.sse()` routes; `undefined` until `.use(streaming())`. */
|
|
190
|
+
sseRuntime;
|
|
191
|
+
/** Installed WebSocket runtime for `.ws()` routes; `undefined` until `.use(websocket())`. */
|
|
192
|
+
wsRuntime;
|
|
702
193
|
logger;
|
|
703
194
|
/** App-wide validation-error fallback; a route's own `schema.onValidationError` takes precedence. */
|
|
704
195
|
defaultOnValidationError;
|
|
@@ -731,6 +222,7 @@ export class Server {
|
|
|
731
222
|
this.maxBodyBytes = options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES;
|
|
732
223
|
this.wsMaxPayloadBytes = options.wsMaxPayloadBytes ?? this.maxBodyBytes;
|
|
733
224
|
this.requestTimeoutMs = options.requestTimeoutMs ?? 0;
|
|
225
|
+
this.clientIpTrust = options.clientIp;
|
|
734
226
|
this.acceptInboundDeadlines = options.acceptInboundDeadlines ?? false;
|
|
735
227
|
this.maxInboundDeadlineMs = options.maxInboundDeadlineMs ?? 30_000;
|
|
736
228
|
this.deadlineAdmissionOptions = Object.freeze({
|
|
@@ -746,26 +238,14 @@ export class Server {
|
|
|
746
238
|
this.gracefulSignals = options.gracefulSignals ?? false;
|
|
747
239
|
this.capacityGate = options.admission;
|
|
748
240
|
this.onCapabilityUse = options.onCapabilityUse;
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
}
|
|
758
|
-
this.effectLedger = Object.freeze({
|
|
759
|
-
sink: ledger.sink,
|
|
760
|
-
maxEntries,
|
|
761
|
-
chain: ledger.chain ?? false,
|
|
762
|
-
});
|
|
763
|
-
}
|
|
764
|
-
else {
|
|
765
|
-
this.effectLedger = undefined;
|
|
766
|
-
}
|
|
767
|
-
this.configuredIdempotencyStore = options.idempotencyStore;
|
|
768
|
-
this.lazyIdempotencyStore = undefined;
|
|
241
|
+
this.capabilityInterceptors = [];
|
|
242
|
+
this.capabilityObservers = [];
|
|
243
|
+
// The effect-ledger runtime is installed by `.use(effectLedger())`; a bare app never imports it, so
|
|
244
|
+
// the ledger machinery tree-shakes out. Capability-declaring routes simply carry no ledger without it.
|
|
245
|
+
this.effectLedgerRuntime = undefined;
|
|
246
|
+
// The idempotency runtime is installed by `.use(idempotency())`; a bare app never imports it, so the
|
|
247
|
+
// dedupe machinery tree-shakes out. A route that declares idempotency without it is a build error.
|
|
248
|
+
this.idempotencyRuntime = undefined;
|
|
769
249
|
this.logger = options.logger ?? jsonLogger();
|
|
770
250
|
this.defaultOnValidationError = options.onValidationError;
|
|
771
251
|
this.bunServer = undefined;
|
|
@@ -828,6 +308,33 @@ export class Server {
|
|
|
828
308
|
this.aroundHooks.push(fn);
|
|
829
309
|
return this;
|
|
830
310
|
}
|
|
311
|
+
/**
|
|
312
|
+
* Asynchronously admit each subsequent `executeCapability()` call before its owned effect runs.
|
|
313
|
+
* Interceptors receive token-only metadata plus an abort signal and must call `next()` exactly once
|
|
314
|
+
* to admit. Returning without `next()`, timing out, aborting, or throwing fails the effect closed.
|
|
315
|
+
*/
|
|
316
|
+
aroundCapability(interceptor, options = {}) {
|
|
317
|
+
this.assertConfigurable("aroundCapability()");
|
|
318
|
+
if (typeof interceptor !== "function") {
|
|
319
|
+
throw new TypeError("aroundCapability interceptor must be a function");
|
|
320
|
+
}
|
|
321
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_CAPABILITY_INTERCEPTOR_TIMEOUT_MS;
|
|
322
|
+
if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0) {
|
|
323
|
+
throw new RangeError("aroundCapability timeoutMs must be a positive safe integer");
|
|
324
|
+
}
|
|
325
|
+
// JS timers wrap larger delays to ~1ms, which would unexpectedly deny every effect.
|
|
326
|
+
this.capabilityInterceptors.push(Object.freeze({ interceptor, timeoutMs: Math.min(timeoutMs, 2_147_483_647) }));
|
|
327
|
+
return this;
|
|
328
|
+
}
|
|
329
|
+
/** Observe token-only admission/execution lifecycle events for subsequent capability routes. */
|
|
330
|
+
observeCapability(observer) {
|
|
331
|
+
this.assertConfigurable("observeCapability()");
|
|
332
|
+
if (typeof observer !== "function") {
|
|
333
|
+
throw new TypeError("observeCapability observer must be a function");
|
|
334
|
+
}
|
|
335
|
+
this.capabilityObservers.push(observer);
|
|
336
|
+
return this;
|
|
337
|
+
}
|
|
831
338
|
/** Transform the handler's result before it is serialized. Order-scoped. */
|
|
832
339
|
afterHandle(fn) {
|
|
833
340
|
this.assertConfigurable("afterHandle()");
|
|
@@ -916,7 +423,8 @@ export class Server {
|
|
|
916
423
|
* JSON-serialized into the SSE `data:` field. The typed client sees the marker and grows a
|
|
917
424
|
* `.subscribe(onEvent)` for the route with the same payload type — end-to-end typed streaming.
|
|
918
425
|
*
|
|
919
|
-
*
|
|
426
|
+
* import { streaming } from "@nifrajs/core/sse" // .use(streaming()) enables .sse()
|
|
427
|
+
* const app = server().use(streaming()).sse("/feed", { sse: t.object({ id: t.integer(), title: t.string() }) },
|
|
920
428
|
* async (c, stream) => {
|
|
921
429
|
* stream.send({ id: 1, title: "hello" }) // typed
|
|
922
430
|
* await waitForDisconnect(stream.signal)
|
|
@@ -930,7 +438,7 @@ export class Server {
|
|
|
930
438
|
* (`stream.signal`). Query/body schemas validate exactly as on any other route.
|
|
931
439
|
*/
|
|
932
440
|
sse(path, schema, run, init) {
|
|
933
|
-
const handler = (context) =>
|
|
441
|
+
const handler = (context) => requireSseRuntime(this.sseRuntime).response(context, (stream) => run(context, stream), init);
|
|
934
442
|
return this.route("GET", path, schema, handler);
|
|
935
443
|
}
|
|
936
444
|
post(path, schemaOrHandler, handler) {
|
|
@@ -946,24 +454,14 @@ export class Server {
|
|
|
946
454
|
return this.route("DELETE", path, schemaOrHandler, handler);
|
|
947
455
|
}
|
|
948
456
|
tool(name, config, handler) {
|
|
949
|
-
const
|
|
950
|
-
|
|
951
|
-
? { body: config.input, response: config.output }
|
|
952
|
-
: { body: config.input };
|
|
953
|
-
// register's handler is the erased `(context: never) => unknown`; the tool's typed input/ctx come from
|
|
954
|
-
// the overload above, so bridge with a single cast rather than `any`.
|
|
955
|
-
const run = (c) => handler(c.body, c);
|
|
956
|
-
this.register("POST", path, routeSchema, run);
|
|
457
|
+
const plan = requireMcpRuntime(this.mcpRuntime).tool(name, config, handler);
|
|
458
|
+
this.register("POST", plan.path, plan.schema, plan.run);
|
|
957
459
|
// Tag the just-registered descriptor as an MCP tool. `tool` is readonly on RouteDescriptor (an
|
|
958
460
|
// introspection field), so write it through a narrow mutable view — not `any`.
|
|
959
461
|
const lastRoute = this.catalog.lastDescriptor();
|
|
960
462
|
if (lastRoute) {
|
|
961
463
|
;
|
|
962
|
-
lastRoute.tool =
|
|
963
|
-
name,
|
|
964
|
-
description: config.description,
|
|
965
|
-
...(config.annotations !== undefined ? { annotations: config.annotations } : {}),
|
|
966
|
-
};
|
|
464
|
+
lastRoute.tool = plan.descriptor;
|
|
967
465
|
}
|
|
968
466
|
return this;
|
|
969
467
|
}
|
|
@@ -975,13 +473,7 @@ export class Server {
|
|
|
975
473
|
*/
|
|
976
474
|
resource(uri, config, read) {
|
|
977
475
|
this.assertConfigurable("resource()");
|
|
978
|
-
this.mcpResourceList.push(
|
|
979
|
-
uri,
|
|
980
|
-
name: config.name,
|
|
981
|
-
...(config.description !== undefined ? { description: config.description } : {}),
|
|
982
|
-
...(config.mimeType !== undefined ? { mimeType: config.mimeType } : {}),
|
|
983
|
-
read,
|
|
984
|
-
});
|
|
476
|
+
this.mcpResourceList.push(requireMcpRuntime(this.mcpRuntime).resource(uri, config, read));
|
|
985
477
|
return this;
|
|
986
478
|
}
|
|
987
479
|
/**
|
|
@@ -990,12 +482,7 @@ export class Server {
|
|
|
990
482
|
*/
|
|
991
483
|
prompt(name, config, handler) {
|
|
992
484
|
this.assertConfigurable("prompt()");
|
|
993
|
-
this.mcpPromptList.push(
|
|
994
|
-
name,
|
|
995
|
-
description: config.description,
|
|
996
|
-
...(config.arguments !== undefined ? { arguments: config.arguments } : {}),
|
|
997
|
-
handler,
|
|
998
|
-
});
|
|
485
|
+
this.mcpPromptList.push(requireMcpRuntime(this.mcpRuntime).prompt(name, config, handler));
|
|
999
486
|
return this;
|
|
1000
487
|
}
|
|
1001
488
|
/** The MCP resources declared via {@link resource} — enumerated by `nifra mcp`. */
|
|
@@ -1013,13 +500,19 @@ export class Server {
|
|
|
1013
500
|
* adapter (`listen()`, `@nifrajs/node`, `@nifrajs/deno`, `toFetchHandler`) — not by bare `app.fetch`, which
|
|
1014
501
|
* has no socket (a WS path through `app.fetch` is a normal HTTP response).
|
|
1015
502
|
*
|
|
503
|
+
* The route also enters the type-level registry (under the pseudo-method `"WS"`), so the typed
|
|
504
|
+
* client grows a `.ws()` handle for it: `messageSchema` types what the client may `send`,
|
|
505
|
+
* `sendSchema` types the frames it receives. Passing explicit type arguments (`ws<MyData>(…)`)
|
|
506
|
+
* defeats path-literal inference and skips the registry entry - the route still serves, it is just
|
|
507
|
+
* invisible to `client<App>`; prefer typing `data` via `upgrade()`'s return.
|
|
508
|
+
*
|
|
1016
509
|
* app.ws("/chat", { open: (ws) => ws.send("hi"), message: (ws, data) => ws.send(data) })
|
|
1017
510
|
*/
|
|
1018
511
|
ws(path, handler) {
|
|
1019
512
|
this.assertConfigurable("ws()");
|
|
1020
513
|
// Boot-time guard: the WS runtime is a subpath (`@nifrajs/core/ws`) so no-WebSocket apps don't
|
|
1021
514
|
// bundle it. Registration is the loud, early failure point — never the first connection.
|
|
1022
|
-
const runtime = requireWsRuntime();
|
|
515
|
+
const runtime = requireWsRuntime(this.wsRuntime);
|
|
1023
516
|
this.topics ??= runtime.createTopics();
|
|
1024
517
|
// A `messageSchema` wraps `message` with validation once, here — every adapter then dispatches
|
|
1025
518
|
// already-validated, typed messages (Bun/Deno/Node/Workers) with no per-adapter code.
|
|
@@ -1081,20 +574,24 @@ export class Server {
|
|
|
1081
574
|
const authenticated = assuranceEvidenceFor([...this.activeAssurance, ...handlerAssurance, ...this.globalAssurance], method, path).some((evidence) => evidence.id === NIFRA_ASSURANCE_IDS.AUTHENTICATED);
|
|
1082
575
|
const routeDecorations = { ...this.decorations };
|
|
1083
576
|
if (capabilities.length > 0) {
|
|
1084
|
-
routeDecorations[CAPABILITY_GUARD] = createCapabilityGuard(capabilities, method, path, this.onCapabilityUse);
|
|
577
|
+
routeDecorations[CAPABILITY_GUARD] = createCapabilityGuard(capabilities, method, path, this.onCapabilityUse, this.idempotencyRuntime?.trackEffect, Object.freeze([...this.capabilityInterceptors]), Object.freeze([...this.capabilityObservers]));
|
|
1085
578
|
}
|
|
1086
579
|
const hasDecorations = Reflect.ownKeys(routeDecorations).length > 0;
|
|
1087
580
|
// An idempotency route runs a dedupe lane that must buffer the body and capture the response, so it
|
|
1088
581
|
// never takes the fused/native fast path — force it onto the portable matched lane (which routes
|
|
1089
|
-
// through `fetchMatched`, where the dedupe wrapper lives).
|
|
1090
|
-
|
|
1091
|
-
//
|
|
582
|
+
// through `fetchMatched`, where the dedupe wrapper lives). Fail closed: a route may not declare
|
|
583
|
+
// idempotency unless the idempotency runtime is installed, so the safety gate can never be silently
|
|
584
|
+
// dropped by a missing plugin.
|
|
585
|
+
if (schema?.idempotency !== undefined && this.idempotencyRuntime === undefined) {
|
|
586
|
+
throw new RouteConfigError("INVALID_IDEMPOTENCY", "route declares idempotency but the idempotency plugin is not installed; add .use(idempotency())");
|
|
587
|
+
}
|
|
588
|
+
const idempotent = this.idempotencyRuntime?.resolve(schema, authenticated, this.maxBodyBytes);
|
|
589
|
+
// A ledgered route (capabilities declared + `.use(effectLedger())`) needs a per-request
|
|
1092
590
|
// context to carry the ledger and a settle step to seal + sink it, so it too leaves the
|
|
1093
591
|
// fused/contextless fast path. Resolved per route, at registration — like the capability guard.
|
|
1094
|
-
const ledgered =
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
const bare = schema?.body === undefined &&
|
|
592
|
+
const ledgered = this.effectLedgerRuntime?.resolve(capabilities, method, path);
|
|
593
|
+
const bare = schema?.params === undefined &&
|
|
594
|
+
schema?.body === undefined &&
|
|
1098
595
|
schema?.query === undefined &&
|
|
1099
596
|
idempotent === undefined &&
|
|
1100
597
|
ledgered === undefined &&
|
|
@@ -1110,6 +607,7 @@ export class Server {
|
|
|
1110
607
|
? "bare"
|
|
1111
608
|
: schema?.body !== undefined &&
|
|
1112
609
|
schema.query === undefined &&
|
|
610
|
+
schema.params === undefined &&
|
|
1113
611
|
this.derives.length === 0 &&
|
|
1114
612
|
this.beforeHandleHooks.length === 0 &&
|
|
1115
613
|
this.afterHandleHooks.length === 0 &&
|
|
@@ -1117,6 +615,7 @@ export class Server {
|
|
|
1117
615
|
? "body"
|
|
1118
616
|
: schema?.body === undefined &&
|
|
1119
617
|
schema?.query !== undefined &&
|
|
618
|
+
schema.params === undefined &&
|
|
1120
619
|
this.derives.length === 0 &&
|
|
1121
620
|
this.beforeHandleHooks.length === 0 &&
|
|
1122
621
|
this.afterHandleHooks.length === 0 &&
|
|
@@ -1145,8 +644,18 @@ export class Server {
|
|
|
1145
644
|
path,
|
|
1146
645
|
schema,
|
|
1147
646
|
...(capabilities.length > 0 ? { capabilities } : {}),
|
|
647
|
+
...(schema?.family === true ? { family: true } : {}),
|
|
1148
648
|
};
|
|
1149
649
|
const routeAssurance = [...this.activeAssurance, ...handlerAssurance];
|
|
650
|
+
// Inline `schema.assurance`: the route DECLARES its enforcement evidence adjacent to the handler, so an
|
|
651
|
+
// in-handler-guarded route satisfies a policy `require:` clause without a `withRouteAssurance` middleware
|
|
652
|
+
// rewrite. Each id becomes route-scoped `declared` evidence (invalid ids fail closed at registration).
|
|
653
|
+
for (const id of schema?.assurance ?? []) {
|
|
654
|
+
if (!validEvidenceId(id)) {
|
|
655
|
+
throw new Error(`route assurance: invalid evidence id ${JSON.stringify(id)} on ${method} ${path} (use lowercase dot/dash segments)`);
|
|
656
|
+
}
|
|
657
|
+
routeAssurance.push(Object.freeze({ id, source: "declared", scope: "plugin" }));
|
|
658
|
+
}
|
|
1150
659
|
if (schema?.body !== undefined) {
|
|
1151
660
|
routeAssurance.push(Object.freeze({
|
|
1152
661
|
id: NIFRA_ASSURANCE_IDS.BODY_BOUNDED,
|
|
@@ -1199,78 +708,52 @@ export class Server {
|
|
|
1199
708
|
const run = (runtime, entry, source, params, search, signal, budget, platform, finalize, wrapResponse) => {
|
|
1200
709
|
const ctx = new RequestContext(source, params, search, signal, budget, platform, runtime.maxBodyBytes);
|
|
1201
710
|
let ledger;
|
|
1202
|
-
|
|
711
|
+
// The runtime is always present when a route resolved a ledger (enforced at registration).
|
|
712
|
+
const ledgerRuntime = runtime.effectLedgerRuntime;
|
|
713
|
+
if (hasLedger && ledgerRuntime !== undefined) {
|
|
1203
714
|
const resolved = entry.ledgered;
|
|
1204
|
-
ledger =
|
|
1205
|
-
|
|
1206
|
-
path: resolved.path,
|
|
1207
|
-
declared: resolved.declared,
|
|
1208
|
-
maxEntries: resolved.maxEntries,
|
|
1209
|
-
chain: resolved.chain,
|
|
1210
|
-
});
|
|
1211
|
-
attachEffectLedger(ctx, ledger);
|
|
715
|
+
ledger = ledgerRuntime.create(resolved);
|
|
716
|
+
ledgerRuntime.attach(ctx, ledger);
|
|
1212
717
|
}
|
|
1213
718
|
let outcome = execute(runtime, entry, source, ctx, finalize, wrapResponse);
|
|
1214
|
-
if (ledger !== undefined) {
|
|
719
|
+
if (ledger !== undefined && ledgerRuntime !== undefined) {
|
|
1215
720
|
const active = ledger;
|
|
1216
721
|
const resolved = entry.ledgered;
|
|
1217
|
-
outcome = (outcome instanceof Promise ? outcome : Promise.resolve(outcome)).then((value) =>
|
|
722
|
+
outcome = (outcome instanceof Promise ? outcome : Promise.resolve(outcome)).then((value) => ledgerRuntime.settle(active, resolved, value, (fields) => runtime.logger.error("effect ledger sink failed", fields)));
|
|
1218
723
|
}
|
|
1219
724
|
return outcome;
|
|
1220
725
|
};
|
|
1221
726
|
return Object.freeze({ run, fusedWeb });
|
|
1222
727
|
}
|
|
1223
|
-
/**
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
throw new RouteConfigError("INVALID_IDEMPOTENCY", "idempotency ttlMs must be a finite positive number");
|
|
1238
|
-
}
|
|
1239
|
-
const headerName = (config.headerName ?? DEFAULT_IDEMPOTENCY_HEADER).toLowerCase();
|
|
1240
|
-
try {
|
|
1241
|
-
new Headers().set(headerName, "probe");
|
|
1242
|
-
}
|
|
1243
|
-
catch {
|
|
1244
|
-
throw new RouteConfigError("INVALID_IDEMPOTENCY", `invalid idempotency header name ${JSON.stringify(headerName)}`);
|
|
1245
|
-
}
|
|
1246
|
-
const namespace = config.namespace;
|
|
1247
|
-
if (namespace === undefined) {
|
|
1248
|
-
throw new RouteConfigError("INVALID_IDEMPOTENCY", "idempotency namespace is required; use a principal resolver or an explicit shared scope");
|
|
1249
|
-
}
|
|
1250
|
-
if (typeof namespace === "string" && !validIdempotencyNamespace(namespace)) {
|
|
1251
|
-
throw new RouteConfigError("INVALID_IDEMPOTENCY", `invalid idempotency namespace ${JSON.stringify(namespace)}`);
|
|
1252
|
-
}
|
|
1253
|
-
if (authenticated && typeof namespace !== "function") {
|
|
1254
|
-
throw new RouteConfigError("INVALID_IDEMPOTENCY", "authenticated idempotency routes require a principal namespace resolver");
|
|
1255
|
-
}
|
|
1256
|
-
const maxResponseBytes = config.maxResponseBytes ?? this.maxBodyBytes;
|
|
1257
|
-
if (!Number.isSafeInteger(maxResponseBytes) || maxResponseBytes < 1) {
|
|
1258
|
-
throw new RouteConfigError("INVALID_IDEMPOTENCY", "idempotency maxResponseBytes must be a positive integer");
|
|
1259
|
-
}
|
|
1260
|
-
return Object.freeze({
|
|
1261
|
-
store,
|
|
1262
|
-
ttlMs,
|
|
1263
|
-
headerName,
|
|
1264
|
-
namespace,
|
|
1265
|
-
maxResponseBytes,
|
|
1266
|
-
});
|
|
728
|
+
/** The idempotency lane's bridge back into the normal matched lanes, resolved to a concrete Response
|
|
729
|
+
* (the lane buffers the body, then replays it through the route's real validation + handler). */
|
|
730
|
+
idempotencyRunLanes(buffered, platform, entry, params, search) {
|
|
731
|
+
return Promise.resolve(this.runMatchedLanes(buffered, platform, entry, params, search, toResponse, IDENTITY_RESPONSE, RESPONSE_TIMEOUT, false));
|
|
732
|
+
}
|
|
733
|
+
/** @internal Symbol-keyed install seam for the `idempotency()` plugin. Off the public typed surface. */
|
|
734
|
+
[INSTALL_IDEMPOTENCY](runtime) {
|
|
735
|
+
this.assertConfigurable("idempotency()");
|
|
736
|
+
this.idempotencyRuntime = runtime;
|
|
737
|
+
}
|
|
738
|
+
/** @internal Symbol-keyed install seam for the `mcp()` plugin. Off the public typed surface. */
|
|
739
|
+
[INSTALL_MCP](runtime) {
|
|
740
|
+
this.assertConfigurable("mcp()");
|
|
741
|
+
this.mcpRuntime = runtime;
|
|
1267
742
|
}
|
|
1268
|
-
/**
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
743
|
+
/** @internal Symbol-keyed install seam for the `nodeDirect()` plugin. Off the public typed surface. */
|
|
744
|
+
[INSTALL_NODE_DIRECT](runtime) {
|
|
745
|
+
this.assertConfigurable("nodeDirect()");
|
|
746
|
+
this.nodeOutcomeRuntime = runtime;
|
|
747
|
+
}
|
|
748
|
+
/** @internal Symbol-keyed install seam for the `streaming()` plugin. Off the public typed surface. */
|
|
749
|
+
[INSTALL_SSE](runtime) {
|
|
750
|
+
this.assertConfigurable("streaming()");
|
|
751
|
+
this.sseRuntime = runtime;
|
|
752
|
+
}
|
|
753
|
+
/** @internal Symbol-keyed install seam for the `websocket()` plugin. Off the public typed surface. */
|
|
754
|
+
[INSTALL_WS](runtime) {
|
|
755
|
+
this.assertConfigurable("websocket()");
|
|
756
|
+
this.wsRuntime = runtime;
|
|
1274
757
|
}
|
|
1275
758
|
/**
|
|
1276
759
|
* Merge another server's routes into this one — the composition escape hatch for large apps.
|
|
@@ -1298,6 +781,17 @@ export class Server {
|
|
|
1298
781
|
throw new RouteConfigError("INVALID_PATH", "merge() does not carry WebSocket routes — register .ws() routes on the parent server");
|
|
1299
782
|
}
|
|
1300
783
|
this.catalog.addBatch(source.catalog.entries().map((route) => this.bindFusedRuntime(route)));
|
|
784
|
+
// Resolved idempotency/ledger route entries carry their own store/sink configuration, while the
|
|
785
|
+
// runtime object supplies the generic execution machinery. Preserve a group's installed runtime
|
|
786
|
+
// when the parent has none so merging cannot silently disable a safety lane. If the parent already
|
|
787
|
+
// has a runtime, either implementation can execute every resolved entry because route-specific
|
|
788
|
+
// options were pinned during registration.
|
|
789
|
+
this.idempotencyRuntime ??= source.idempotencyRuntime;
|
|
790
|
+
this.effectLedgerRuntime ??= source.effectLedgerRuntime;
|
|
791
|
+
this.mcpRuntime ??= source.mcpRuntime;
|
|
792
|
+
this.nodeOutcomeRuntime ??= source.nodeOutcomeRuntime;
|
|
793
|
+
this.sseRuntime ??= source.sseRuntime;
|
|
794
|
+
this.wsRuntime ??= source.wsRuntime;
|
|
1301
795
|
this.onRequestHooks.push(...source.onRequestHooks);
|
|
1302
796
|
this.onResponseHooks.push(...source.onResponseHooks);
|
|
1303
797
|
this.onResponseFinalizedHooks.push(...source.onResponseFinalizedHooks);
|
|
@@ -1434,7 +928,7 @@ export class Server {
|
|
|
1434
928
|
resolveNode(req, platform) {
|
|
1435
929
|
return this.resolveNodeSource(req, platform);
|
|
1436
930
|
}
|
|
1437
|
-
resolveNodeSource(source, platform) {
|
|
931
|
+
resolveNodeSource(source, platform, suppliedRuntime) {
|
|
1438
932
|
// onResponse hooks transform a Response, and the capacity gate wraps the Web response path — both
|
|
1439
933
|
// force the Web path here (the gated `fetchSource` admits/sheds/releases); wrap its result.
|
|
1440
934
|
if (this.onResponseHooks.length > 0 ||
|
|
@@ -1448,7 +942,11 @@ export class Server {
|
|
|
1448
942
|
// May resolve **synchronously** for a compiled bare route + sync handler - the `@nifrajs/node`
|
|
1449
943
|
// adapter `await`s the result, so it transparently handles either; the sync case allocates no promise
|
|
1450
944
|
// at all on the Node hot path.
|
|
1451
|
-
|
|
945
|
+
const runtime = suppliedRuntime ?? this.nodeOutcomeRuntime;
|
|
946
|
+
if (runtime === undefined) {
|
|
947
|
+
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`).");
|
|
948
|
+
}
|
|
949
|
+
return this.dispatch(source, platform, runtime.toOutcome, runtime.fromResponse, runtime.timeout, false);
|
|
1452
950
|
}
|
|
1453
951
|
/**
|
|
1454
952
|
* Resolve a WebSocket upgrade — the seam every serving adapter uses. Returns `pass` (not a WS
|
|
@@ -1473,8 +971,9 @@ export class Server {
|
|
|
1473
971
|
if (params === null)
|
|
1474
972
|
return { kind: "reject", response: jsonError(400, "malformed_path") };
|
|
1475
973
|
const handler = match.payload.handler;
|
|
1476
|
-
// Non-null: wsRouteCount > 0 ⇒ ws() ran ⇒ the runtime
|
|
974
|
+
// Non-null: wsRouteCount > 0 ⇒ ws() ran ⇒ `.use(websocket())` installed the runtime + registry.
|
|
1477
975
|
const pubsub = this.topics;
|
|
976
|
+
const attach = this.wsRuntime.attach;
|
|
1478
977
|
// CSWSH guard, before any per-connection work or the user's upgrade(): reject a disallowed
|
|
1479
978
|
// Origin with 403. Browsers don't CORS-protect WS handshakes but do send cookies, so this
|
|
1480
979
|
// blocks cross-site authenticated sockets when the route opts in via `allowedOrigins`.
|
|
@@ -1494,13 +993,13 @@ export class Server {
|
|
|
1494
993
|
return { kind: "reject", response: jsonError(403, "forbidden_origin") };
|
|
1495
994
|
}
|
|
1496
995
|
if (handler.upgrade === undefined) {
|
|
1497
|
-
return { kind: "upgrade", handler, data: undefined, pubsub };
|
|
996
|
+
return { kind: "upgrade", handler, data: undefined, pubsub, attach };
|
|
1498
997
|
}
|
|
1499
998
|
const upgradeSignal = getNeverAbortSignal();
|
|
1500
999
|
const ctx = new RequestContext(req, params, url.search, upgradeSignal, createUnboundedRequestBudget(upgradeSignal), platform, this.maxBodyBytes);
|
|
1501
1000
|
const settle = (value) => value instanceof Response
|
|
1502
1001
|
? { kind: "reject", response: value }
|
|
1503
|
-
: { kind: "upgrade", handler, data: value, pubsub };
|
|
1002
|
+
: { kind: "upgrade", handler, data: value, pubsub, attach };
|
|
1504
1003
|
try {
|
|
1505
1004
|
const result = handler.upgrade(ctx);
|
|
1506
1005
|
return result instanceof Promise
|
|
@@ -1520,7 +1019,7 @@ export class Server {
|
|
|
1520
1019
|
bunFetchWithWebSocket(req, server) {
|
|
1521
1020
|
const handle = (o) => {
|
|
1522
1021
|
if (o.kind === "pass")
|
|
1523
|
-
return this.fetch(req);
|
|
1022
|
+
return this.fetch(req, bunPeerPlatform(server, req));
|
|
1524
1023
|
if (o.kind === "reject")
|
|
1525
1024
|
return o.response;
|
|
1526
1025
|
return server.upgrade(req, { data: { handler: o.handler, data: o.data } })
|
|
@@ -1537,16 +1036,26 @@ export class Server {
|
|
|
1537
1036
|
* produces the 503. The Web `fetch` and `resolveNode` are thin callers over this one routing +
|
|
1538
1037
|
* context + lifecycle implementation — no duplication across the trust boundary.
|
|
1539
1038
|
*/
|
|
1039
|
+
/** Apply the `clientIp` trust declaration to the adapter's raw socket peer, returning a platform
|
|
1040
|
+
* whose `clientIp` is the derived caller. Only called when a trust declaration is configured. */
|
|
1041
|
+
deriveClientIp(source, platform) {
|
|
1042
|
+
const derived = resolveClientIp(platform?.clientIp, requestOf(source), this.clientIpTrust);
|
|
1043
|
+
return { ...platform, clientIp: derived };
|
|
1044
|
+
}
|
|
1540
1045
|
dispatch(source, platform, finalize, wrapResponse, onTimeout,
|
|
1541
1046
|
// True only from the Web `fetch` path — unlocks each route's fused lane, whose output type IS
|
|
1542
1047
|
// `Response` (`T = Response` there by construction; the node path always passes false).
|
|
1543
1048
|
webFast) {
|
|
1049
|
+
// Resolve the trust declaration into the platform's `clientIp` ONCE, here at the shared funnel, so
|
|
1050
|
+
// `c.clientIp` (and every hook/derive downstream) sees the derived caller. No config ⇒ the raw
|
|
1051
|
+
// socket peer the adapter supplied passes through untouched (a one-property no-op on the hot path).
|
|
1052
|
+
const resolved = this.clientIpTrust === undefined ? platform : this.deriveClientIp(source, platform);
|
|
1544
1053
|
// onRequest hooks may be async, so a hooked app takes the async path; with no hooks (the common
|
|
1545
1054
|
// case) routing stays synchronous, letting a bare route resolve with no lifecycle promise at all.
|
|
1546
1055
|
if (this.onRequestHooks.length === 0) {
|
|
1547
|
-
return this.routeAndRun(source,
|
|
1056
|
+
return this.routeAndRun(source, resolved, finalize, wrapResponse, onTimeout, webFast);
|
|
1548
1057
|
}
|
|
1549
|
-
return this.runWithOnRequest(source,
|
|
1058
|
+
return this.runWithOnRequest(source, resolved, finalize, wrapResponse, onTimeout, webFast);
|
|
1550
1059
|
}
|
|
1551
1060
|
/**
|
|
1552
1061
|
* onRequest short-circuit path. Synchronous as long as every hook returns synchronously (the
|
|
@@ -1632,113 +1141,14 @@ export class Server {
|
|
|
1632
1141
|
runMatched(source, platform, entry, params, search, finalize, wrapResponse, onTimeout, webFast) {
|
|
1633
1142
|
// An idempotency route runs its dedupe lane first; on a fresh key it delegates to the normal lanes
|
|
1634
1143
|
// (with the body buffered). All non-idempotent routes skip straight to the lanes — no added cost.
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
/**
|
|
1641
|
-
* Idempotency dedupe lane. Reads the `Idempotency-Key`, fingerprints the request, and consults the
|
|
1642
|
-
* store: a fresh key runs the handler (via {@link runMatchedLanes} with the body buffered) and stores
|
|
1643
|
-
* the response; a repeat key replays it without re-running; a key reused with a different body or an
|
|
1644
|
-
* in-flight duplicate is rejected. Every branch materializes a `Response`, then adapts to `T` via the
|
|
1645
|
-
* caller's `wrapResponse`, so it works on the Web fetch path and the Node-native outcome path alike.
|
|
1646
|
-
*/
|
|
1647
|
-
async runIdempotent(source, platform, entry, params, search, wrapResponse) {
|
|
1648
|
-
const config = entry.idempotent;
|
|
1649
|
-
const req = requestOf(source);
|
|
1650
|
-
const key = req.headers.get(config.headerName);
|
|
1651
|
-
if (key === null || !validIdempotencyKey(key)) {
|
|
1652
|
-
return wrapResponse(jsonError(400, "idempotency_key_required"));
|
|
1653
|
-
}
|
|
1654
|
-
const namespace = typeof config.namespace === "function"
|
|
1655
|
-
? await config.namespace(req.clone(), platform)
|
|
1656
|
-
: config.namespace;
|
|
1657
|
-
if (!validIdempotencyNamespace(namespace)) {
|
|
1658
|
-
return wrapResponse(jsonError(500, "idempotency_namespace_invalid"));
|
|
1659
|
-
}
|
|
1660
|
-
const read = await readBoundedBytes(req, this.maxBodyBytes);
|
|
1661
|
-
if (!read.ok) {
|
|
1662
|
-
return wrapResponse(jsonError(read.status, read.status === 413 ? "payload_too_large" : "bad_request"));
|
|
1663
|
-
}
|
|
1664
|
-
const url = urlPartsOf(req.url);
|
|
1665
|
-
const canonicalBody = canonicalizeIdempotencyBody(read.bytes, req.headers.get("content-type"));
|
|
1666
|
-
const fingerprint = await computeIdempotencyFingerprint(req.method, `${url.pathname}${url.search}`, canonicalBody, req.headers.get("content-type") ?? "");
|
|
1667
|
-
const begin = await config.store.begin({ namespace, key, fingerprint, ttlMs: config.ttlMs });
|
|
1668
|
-
if (begin.state === "replay") {
|
|
1669
|
-
return wrapResponse(responseFromStored(begin.response, { maxBytes: config.maxResponseBytes }));
|
|
1670
|
-
}
|
|
1671
|
-
if (begin.state === "mismatch")
|
|
1672
|
-
return wrapResponse(jsonError(409, "idempotency_key_reused"));
|
|
1673
|
-
if (begin.state === "in-flight") {
|
|
1674
|
-
return wrapResponse(jsonError(409, "idempotency_in_progress", { "Retry-After": "1" }));
|
|
1675
|
-
}
|
|
1676
|
-
if (begin.state === "capacity") {
|
|
1677
|
-
return wrapResponse(jsonError(503, "idempotency_store_capacity", { "Retry-After": "1" }));
|
|
1678
|
-
}
|
|
1679
|
-
const reservation = begin.reservation;
|
|
1680
|
-
// Fresh key: re-expose the buffered body as a Request so the normal lanes can read + validate it,
|
|
1681
|
-
// then run those lanes to a concrete Response.
|
|
1682
|
-
const bufferedInit = {
|
|
1683
|
-
method: req.method,
|
|
1684
|
-
headers: req.headers,
|
|
1685
|
-
signal: req.signal,
|
|
1686
|
-
};
|
|
1687
|
-
if (read.bytes.byteLength > 0)
|
|
1688
|
-
bufferedInit.body = read.bytes;
|
|
1689
|
-
const buffered = new Request(req.url, bufferedInit);
|
|
1690
|
-
let response;
|
|
1691
|
-
try {
|
|
1692
|
-
response = await this.runMatchedLanes(buffered, platform, entry, params, search, toResponse, IDENTITY_RESPONSE, RESPONSE_TIMEOUT, false);
|
|
1693
|
-
}
|
|
1694
|
-
catch (err) {
|
|
1695
|
-
// The lanes never throw (errors resolve to a 500 Response), but stay fail-safe: release the
|
|
1696
|
-
// reservation so a retry isn't wedged, then re-throw.
|
|
1697
|
-
await config.store.abandon({ namespace, key, reservation });
|
|
1698
|
-
throw err;
|
|
1699
|
-
}
|
|
1700
|
-
// Cache only successful responses; release the key on any error so the client can retry.
|
|
1701
|
-
if (response.status >= 200 && response.status < 300) {
|
|
1702
|
-
let storedResponse;
|
|
1703
|
-
try {
|
|
1704
|
-
storedResponse = await serializeResponse(response, { maxBytes: config.maxResponseBytes });
|
|
1705
|
-
}
|
|
1706
|
-
catch (error) {
|
|
1707
|
-
if (!(error instanceof IdempotencyResponseTooLargeError))
|
|
1708
|
-
throw error;
|
|
1709
|
-
// The effect may already have happened, so never abandon and permit a duplicate execution.
|
|
1710
|
-
// Commit a small terminal response under the winning key and return that same response now.
|
|
1711
|
-
response = jsonError(507, "idempotency_response_too_large");
|
|
1712
|
-
try {
|
|
1713
|
-
storedResponse = await serializeResponse(response, {
|
|
1714
|
-
maxBytes: config.maxResponseBytes,
|
|
1715
|
-
});
|
|
1716
|
-
}
|
|
1717
|
-
catch (terminalError) {
|
|
1718
|
-
if (!(terminalError instanceof IdempotencyResponseTooLargeError))
|
|
1719
|
-
throw terminalError;
|
|
1720
|
-
// Even an intentionally tiny bound must remain truthful. An empty 507 preserves terminal
|
|
1721
|
-
// status + replay safety without silently storing more bytes than the route permits.
|
|
1722
|
-
response = new Response(null, { status: 507 });
|
|
1723
|
-
storedResponse = await serializeResponse(response, {
|
|
1724
|
-
maxBytes: config.maxResponseBytes,
|
|
1725
|
-
});
|
|
1726
|
-
}
|
|
1727
|
-
}
|
|
1728
|
-
const completed = await config.store.complete({
|
|
1729
|
-
namespace,
|
|
1730
|
-
key,
|
|
1731
|
-
reservation,
|
|
1732
|
-
response: storedResponse,
|
|
1144
|
+
// The runtime is always present when a route resolved idempotency (enforced at registration).
|
|
1145
|
+
if (entry.idempotent !== undefined && this.idempotencyRuntime !== undefined) {
|
|
1146
|
+
return this.idempotencyRuntime.run(entry.idempotent, requestOf(source), platform, entry, params, search, wrapResponse, {
|
|
1147
|
+
maxBodyBytes: this.maxBodyBytes,
|
|
1148
|
+
runLanes: (buffered, plat, ent, prm, srch) => this.idempotencyRunLanes(buffered, plat, ent, prm, srch),
|
|
1733
1149
|
});
|
|
1734
|
-
if (!completed) {
|
|
1735
|
-
return wrapResponse(jsonError(503, "idempotency_reservation_lost", { "Retry-After": "1" }));
|
|
1736
|
-
}
|
|
1737
|
-
}
|
|
1738
|
-
else {
|
|
1739
|
-
await config.store.abandon({ namespace, key, reservation });
|
|
1740
1150
|
}
|
|
1741
|
-
return wrapResponse
|
|
1151
|
+
return this.runMatchedLanes(source, platform, entry, params, search, finalize, wrapResponse, onTimeout, webFast);
|
|
1742
1152
|
}
|
|
1743
1153
|
/** Supply request-specific deadline state to the route's precompiled execution plan. */
|
|
1744
1154
|
runMatchedLanes(source, platform, entry, params, search, finalize, wrapResponse, onTimeout, webFast) {
|
|
@@ -1788,30 +1198,10 @@ export class Server {
|
|
|
1788
1198
|
}
|
|
1789
1199
|
return outcome;
|
|
1790
1200
|
}
|
|
1791
|
-
/**
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
* the owned effect transaction, so a sink outage must not turn a successful effect into a retryable
|
|
1796
|
-
* 500 that can duplicate it.
|
|
1797
|
-
*/
|
|
1798
|
-
async settleEffectLedger(ledger, resolved, value) {
|
|
1799
|
-
const sealed = await ledger.seal();
|
|
1800
|
-
if (sealed.entries.length === 0)
|
|
1801
|
-
return value;
|
|
1802
|
-
try {
|
|
1803
|
-
await resolved.sink(sealed);
|
|
1804
|
-
}
|
|
1805
|
-
catch (err) {
|
|
1806
|
-
// Token-only logging: the route pattern and the failure name — never entry payloads (there are
|
|
1807
|
-
// none) and never the request.
|
|
1808
|
-
this.logger.error("effect ledger sink failed", {
|
|
1809
|
-
method: resolved.method,
|
|
1810
|
-
path: resolved.path,
|
|
1811
|
-
name: err instanceof Error ? err.name : "Error",
|
|
1812
|
-
});
|
|
1813
|
-
}
|
|
1814
|
-
return value;
|
|
1201
|
+
/** @internal Symbol-keyed install seam for the `effectLedger()` plugin. Off the public typed surface. */
|
|
1202
|
+
[INSTALL_EFFECT_LEDGER](runtime) {
|
|
1203
|
+
this.assertConfigurable("effectLedger()");
|
|
1204
|
+
this.effectLedgerRuntime = runtime;
|
|
1815
1205
|
}
|
|
1816
1206
|
/** The narrowest bare route: a syntactic `() => ...` handler cannot observe the context argument, so
|
|
1817
1207
|
* successful requests can skip allocating `RequestContext`. Errors still allocate one for logging. */
|
|
@@ -2239,6 +1629,16 @@ export class Server {
|
|
|
2239
1629
|
*/
|
|
2240
1630
|
async runLifecycle(entry, source, ctx, finalize, wrapResponse) {
|
|
2241
1631
|
try {
|
|
1632
|
+
if (entry.schema?.params !== undefined) {
|
|
1633
|
+
// Path params arrive as strings from routing; validate (and coerce) them at the boundary, before
|
|
1634
|
+
// the body/query, so a malformed `:id` is a 422 before any work runs. Raw `.validate` (no wrapper
|
|
1635
|
+
// alloc), same recovery-hook path as body/query.
|
|
1636
|
+
const validation = entry.schema.params["~standard"].validate(ctx.params);
|
|
1637
|
+
const result = validation instanceof Promise ? await validation : validation;
|
|
1638
|
+
const paramsError = await this.applyLifecycleValidation(entry, result, ctx, "params");
|
|
1639
|
+
if (paramsError !== undefined)
|
|
1640
|
+
return wrapResponse(paramsError);
|
|
1641
|
+
}
|
|
2242
1642
|
if (entry.schema?.body !== undefined) {
|
|
2243
1643
|
const bodyError = await this.readAndValidateBody(source, entry, ctx);
|
|
2244
1644
|
if (bodyError !== undefined)
|
|
@@ -2349,11 +1749,16 @@ export class Server {
|
|
|
2349
1749
|
/** Apply validation and its recovery hook on the generic lifecycle lane. Recovery is completed
|
|
2350
1750
|
* before derives/beforeHandle run, matching the body-only and query-only execution lanes. */
|
|
2351
1751
|
async applyLifecycleValidation(entry, result, ctx, kind) {
|
|
2352
|
-
|
|
1752
|
+
const assign = (value) => {
|
|
2353
1753
|
if (kind === "body")
|
|
2354
|
-
ctx.body =
|
|
1754
|
+
ctx.body = value;
|
|
1755
|
+
else if (kind === "query")
|
|
1756
|
+
ctx.query = value;
|
|
2355
1757
|
else
|
|
2356
|
-
ctx.
|
|
1758
|
+
ctx.params = value;
|
|
1759
|
+
};
|
|
1760
|
+
if (result.issues === undefined) {
|
|
1761
|
+
assign(result.value);
|
|
2357
1762
|
return undefined;
|
|
2358
1763
|
}
|
|
2359
1764
|
const hook = entry.schema?.onValidationError ?? this.defaultOnValidationError;
|
|
@@ -2365,15 +1770,16 @@ export class Server {
|
|
|
2365
1770
|
return validationError(result.issues);
|
|
2366
1771
|
if (recovery instanceof Response)
|
|
2367
1772
|
return recovery;
|
|
2368
|
-
const schema = kind === "body"
|
|
1773
|
+
const schema = kind === "body"
|
|
1774
|
+
? entry.schema?.body
|
|
1775
|
+
: kind === "query"
|
|
1776
|
+
? entry.schema?.query
|
|
1777
|
+
: entry.schema?.params;
|
|
2369
1778
|
const retried = schema["~standard"].validate(recovery);
|
|
2370
1779
|
const settled = retried instanceof Promise ? await retried : retried;
|
|
2371
1780
|
if (settled.issues !== undefined)
|
|
2372
1781
|
return validationError(settled.issues);
|
|
2373
|
-
|
|
2374
|
-
ctx.body = settled.value;
|
|
2375
|
-
else
|
|
2376
|
-
ctx.query = settled.value;
|
|
1782
|
+
assign(settled.value);
|
|
2377
1783
|
return undefined;
|
|
2378
1784
|
}
|
|
2379
1785
|
/**
|
|
@@ -2440,8 +1846,14 @@ export class Server {
|
|
|
2440
1846
|
* route selection/upgrade. Named wildcards also stay on the fallback until Bun exposes their raw
|
|
2441
1847
|
* capture semantics; static and `:param` routes take the native lane. */
|
|
2442
1848
|
buildBunNativeRoutes() {
|
|
2443
|
-
|
|
1849
|
+
// A `clientIp` trust declaration must run the resolver in `dispatch`, which the fused native lane
|
|
1850
|
+
// bypasses — so an app that declares trust routes through the fetch lane (where `c.clientIp`
|
|
1851
|
+
// resolves) instead of Bun's native table. The allocation-free default keeps native fusion.
|
|
1852
|
+
if (this.onRequestHooks.length > 0 ||
|
|
1853
|
+
this.wsRouteCount > 0 ||
|
|
1854
|
+
this.clientIpTrust !== undefined) {
|
|
2444
1855
|
return undefined;
|
|
1856
|
+
}
|
|
2445
1857
|
const routes = Object.create(null);
|
|
2446
1858
|
const mayUseFusedNative = this.requestTimeoutMs === 0 &&
|
|
2447
1859
|
this.onResponseHooks.length === 0 &&
|
|
@@ -2498,11 +1910,11 @@ export class Server {
|
|
|
2498
1910
|
// `server` 2nd arg is how Bun exposes `upgrade`); otherwise the lean request-only fetch. The
|
|
2499
1911
|
// `websocket` handlers are one shared dispatcher — each connection's `ws.data.handler` is the
|
|
2500
1912
|
// matched route's handler, set by `server.upgrade`.
|
|
2501
|
-
// With WS routes, the dispatcher comes from the
|
|
2502
|
-
// wsRouteCount > 0 means ws() ran, and ws() requires the runtime at registration.
|
|
1913
|
+
// With WS routes, the dispatcher comes from the installed `.use(websocket())` runtime — non-null
|
|
1914
|
+
// because wsRouteCount > 0 means ws() ran, and ws() requires the runtime at registration.
|
|
2503
1915
|
const wsHandlers = this.wsRouteCount === 0
|
|
2504
1916
|
? undefined
|
|
2505
|
-
:
|
|
1917
|
+
: this.wsRuntime.bunHandlers(this.topics);
|
|
2506
1918
|
const reusePort = options?.reusePort === true;
|
|
2507
1919
|
// Spread rather than pass `hostname: undefined` - Bun treats an explicit undefined as a value
|
|
2508
1920
|
// on some option paths, and omitting is what selects its 0.0.0.0 default.
|
|
@@ -2514,7 +1926,7 @@ export class Server {
|
|
|
2514
1926
|
reusePort,
|
|
2515
1927
|
...bind,
|
|
2516
1928
|
...(nativeRoutes === undefined ? {} : { routes: nativeRoutes }),
|
|
2517
|
-
fetch: (req) => this.fetch(req),
|
|
1929
|
+
fetch: (req, server) => this.fetch(req, bunPeerPlatform(server, req)),
|
|
2518
1930
|
})
|
|
2519
1931
|
: Bun.serve({
|
|
2520
1932
|
port,
|
|
@@ -2613,7 +2025,9 @@ export function toFetchHandler(app, options) {
|
|
|
2613
2025
|
const pair = new Pair();
|
|
2614
2026
|
const server = pair[1];
|
|
2615
2027
|
server.accept();
|
|
2616
|
-
|
|
2028
|
+
// The outcome carries the installed runtime's `attach`, so `toFetchHandler` (a standalone
|
|
2029
|
+
// function, no access to the app's private runtime) wires the socket without a static WS import.
|
|
2030
|
+
outcome.attach(server, outcome.handler, outcome.data, {
|
|
2617
2031
|
openNow: true,
|
|
2618
2032
|
pubsub: outcome.pubsub,
|
|
2619
2033
|
});
|