@nifrajs/core 3.5.0 → 4.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 +26 -0
- package/dist/assurance.d.ts +7 -0
- package/dist/assurance.d.ts.map +1 -1
- package/dist/assurance.js +39 -3
- package/dist/assurance.js.map +1 -1
- package/dist/capabilities.d.ts +20 -2
- package/dist/capabilities.d.ts.map +1 -1
- package/dist/capabilities.js +47 -15
- package/dist/capabilities.js.map +1 -1
- package/dist/diff.d.ts +2 -1
- package/dist/diff.d.ts.map +1 -1
- package/dist/diff.js +26 -2
- package/dist/diff.js.map +1 -1
- package/dist/durable-adapters.d.ts.map +1 -1
- package/dist/durable-adapters.js +16 -3
- package/dist/durable-adapters.js.map +1 -1
- package/dist/durable-effect.d.ts.map +1 -1
- package/dist/durable-effect.js +6 -3
- package/dist/durable-effect.js.map +1 -1
- package/dist/edge-kit.d.ts +2 -0
- package/dist/edge-kit.d.ts.map +1 -1
- package/dist/edge-kit.js +2 -0
- package/dist/edge-kit.js.map +1 -1
- package/dist/effect-lifecycle.d.ts.map +1 -1
- package/dist/effect-lifecycle.js +2 -1
- package/dist/effect-lifecycle.js.map +1 -1
- package/dist/effect-scope.d.ts.map +1 -1
- package/dist/effect-scope.js +1 -1
- package/dist/effect-scope.js.map +1 -1
- package/dist/errors.d.ts +1 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js.map +1 -1
- package/dist/evidence.d.ts +24 -1
- package/dist/evidence.d.ts.map +1 -1
- package/dist/evidence.js +168 -13
- package/dist/evidence.js.map +1 -1
- package/dist/idempotency.d.ts +31 -0
- package/dist/idempotency.d.ts.map +1 -1
- package/dist/idempotency.js +40 -8
- package/dist/idempotency.js.map +1 -1
- package/dist/index.d.ts +8 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -2
- package/dist/index.js.map +1 -1
- package/dist/internal/code-unit-order.d.ts +7 -0
- package/dist/internal/code-unit-order.d.ts.map +1 -0
- package/dist/internal/code-unit-order.js +9 -0
- package/dist/internal/code-unit-order.js.map +1 -0
- package/dist/internal/durable-saga-protocol.d.ts.map +1 -1
- package/dist/internal/durable-saga-protocol.js +2 -0
- package/dist/internal/durable-saga-protocol.js.map +1 -1
- package/dist/internal/route-catalog.d.ts +6 -6
- package/dist/internal/route-catalog.d.ts.map +1 -1
- package/dist/internal/route-catalog.js +20 -18
- package/dist/internal/route-catalog.js.map +1 -1
- package/dist/internal/route-compiler.d.ts +7 -6
- package/dist/internal/route-compiler.d.ts.map +1 -1
- package/dist/internal/route-compiler.js +36 -4
- package/dist/internal/route-compiler.js.map +1 -1
- package/dist/internal/route-execution.d.ts +7 -3
- package/dist/internal/route-execution.d.ts.map +1 -1
- package/dist/internal/route-execution.js.map +1 -1
- package/dist/internal/route-lanes.d.ts +1 -0
- package/dist/internal/route-lanes.d.ts.map +1 -1
- package/dist/internal/route-lanes.js +34 -29
- package/dist/internal/route-lanes.js.map +1 -1
- package/dist/internal/route-program.d.ts +8 -2
- package/dist/internal/route-program.d.ts.map +1 -1
- package/dist/internal/route-program.js +222 -20
- package/dist/internal/route-program.js.map +1 -1
- package/dist/ledger.d.ts.map +1 -1
- package/dist/ledger.js +8 -5
- package/dist/ledger.js.map +1 -1
- package/dist/manifest.d.ts.map +1 -1
- package/dist/manifest.js +41 -37
- package/dist/manifest.js.map +1 -1
- package/dist/mount.d.ts +36 -0
- package/dist/mount.d.ts.map +1 -1
- package/dist/mount.js +32 -0
- package/dist/mount.js.map +1 -1
- package/dist/reflection.d.ts +26 -0
- package/dist/reflection.d.ts.map +1 -1
- package/dist/reflection.js +65 -1
- package/dist/reflection.js.map +1 -1
- package/dist/response-observer.d.ts +1 -1
- package/dist/response-observer.d.ts.map +1 -1
- package/dist/response-observer.js.map +1 -1
- package/dist/router/overlap.d.ts +23 -0
- package/dist/router/overlap.d.ts.map +1 -0
- package/dist/router/overlap.js +224 -0
- package/dist/router/overlap.js.map +1 -0
- package/dist/router/pattern.d.ts +117 -2
- package/dist/router/pattern.d.ts.map +1 -1
- package/dist/router/pattern.js +283 -23
- package/dist/router/pattern.js.map +1 -1
- package/dist/router/router.d.ts +18 -4
- package/dist/router/router.d.ts.map +1 -1
- package/dist/router/router.js +51 -44
- package/dist/router/router.js.map +1 -1
- package/dist/server/auth.d.ts +53 -0
- package/dist/server/auth.d.ts.map +1 -0
- package/dist/server/auth.js +9 -0
- package/dist/server/auth.js.map +1 -0
- package/dist/server/bare-error-lane.d.ts +12 -7
- package/dist/server/bare-error-lane.d.ts.map +1 -1
- package/dist/server/bare-error-lane.js +13 -11
- package/dist/server/bare-error-lane.js.map +1 -1
- package/dist/server/body-lane.d.ts +20 -2
- package/dist/server/body-lane.d.ts.map +1 -1
- package/dist/server/body-lane.js +26 -7
- package/dist/server/body-lane.js.map +1 -1
- package/dist/server/body-parser.d.ts +68 -0
- package/dist/server/body-parser.d.ts.map +1 -0
- package/dist/server/body-parser.js +172 -0
- package/dist/server/body-parser.js.map +1 -0
- package/dist/server/body.d.ts.map +1 -1
- package/dist/server/body.js +62 -7
- package/dist/server/body.js.map +1 -1
- package/dist/server/client-ip.d.ts +8 -0
- package/dist/server/client-ip.d.ts.map +1 -1
- package/dist/server/client-ip.js +8 -0
- package/dist/server/client-ip.js.map +1 -1
- package/dist/server/context.d.ts +71 -6
- package/dist/server/context.d.ts.map +1 -1
- package/dist/server/contract.d.ts +15 -5
- package/dist/server/contract.d.ts.map +1 -1
- package/dist/server/contract.js +25 -4
- package/dist/server/contract.js.map +1 -1
- package/dist/server/core-copies.d.ts +2 -0
- package/dist/server/core-copies.d.ts.map +1 -0
- package/dist/server/core-copies.js +20 -0
- package/dist/server/core-copies.js.map +1 -0
- package/dist/server/edge.d.ts.map +1 -1
- package/dist/server/edge.js +2 -0
- package/dist/server/edge.js.map +1 -1
- package/dist/server/http.d.ts +11 -0
- package/dist/server/http.d.ts.map +1 -1
- package/dist/server/http.js +36 -0
- package/dist/server/http.js.map +1 -1
- package/dist/server/idempotency-lane.d.ts +6 -0
- package/dist/server/idempotency-lane.d.ts.map +1 -1
- package/dist/server/idempotency-lane.js +130 -73
- package/dist/server/idempotency-lane.js.map +1 -1
- package/dist/server/install.d.ts +4 -0
- package/dist/server/install.d.ts.map +1 -1
- package/dist/server/install.js +4 -0
- package/dist/server/install.js.map +1 -1
- package/dist/server/methods.d.ts +45 -0
- package/dist/server/methods.d.ts.map +1 -0
- package/dist/server/methods.js +37 -0
- package/dist/server/methods.js.map +1 -0
- package/dist/server/multipart.d.ts +63 -0
- package/dist/server/multipart.d.ts.map +1 -0
- package/dist/server/multipart.js +374 -0
- package/dist/server/multipart.js.map +1 -0
- package/dist/server/node-outcome-hook.d.ts +20 -0
- package/dist/server/node-outcome-hook.d.ts.map +1 -1
- package/dist/server/node-outcome-hook.js +15 -6
- package/dist/server/node-outcome-hook.js.map +1 -1
- package/dist/server/not-found-answer.d.ts +40 -0
- package/dist/server/not-found-answer.d.ts.map +1 -0
- package/dist/server/not-found-answer.js +56 -0
- package/dist/server/not-found-answer.js.map +1 -0
- package/dist/server/not-found.d.ts +23 -0
- package/dist/server/not-found.d.ts.map +1 -0
- package/dist/server/not-found.js +81 -0
- package/dist/server/not-found.js.map +1 -0
- package/dist/server/plugin.d.ts +26 -1
- package/dist/server/plugin.d.ts.map +1 -1
- package/dist/server/plugin.js.map +1 -1
- package/dist/server/proto-guard.d.ts +18 -5
- package/dist/server/proto-guard.d.ts.map +1 -1
- package/dist/server/proto-guard.js +114 -9
- package/dist/server/proto-guard.js.map +1 -1
- package/dist/server/query.d.ts +7 -0
- package/dist/server/query.d.ts.map +1 -1
- package/dist/server/query.js +47 -0
- package/dist/server/query.js.map +1 -1
- package/dist/server/registry.d.ts +13 -2
- package/dist/server/registry.d.ts.map +1 -1
- package/dist/server/request-context.d.ts +11 -0
- package/dist/server/request-context.d.ts.map +1 -1
- package/dist/server/request-context.js +33 -19
- package/dist/server/request-context.js.map +1 -1
- package/dist/server/request-lineage.d.ts +8 -0
- package/dist/server/request-lineage.d.ts.map +1 -0
- package/dist/server/request-lineage.js +14 -0
- package/dist/server/request-lineage.js.map +1 -0
- package/dist/server/request-target.d.ts +31 -0
- package/dist/server/request-target.d.ts.map +1 -0
- package/dist/server/request-target.js +44 -0
- package/dist/server/request-target.js.map +1 -0
- package/dist/server/respond.d.ts +1 -1
- package/dist/server/respond.d.ts.map +1 -1
- package/dist/server/respond.js +16 -4
- package/dist/server/respond.js.map +1 -1
- package/dist/server/response-contract-lane.d.ts +19 -1
- package/dist/server/response-contract-lane.d.ts.map +1 -1
- package/dist/server/response-contract-lane.js +59 -14
- package/dist/server/response-contract-lane.js.map +1 -1
- package/dist/server/response-observer-runtime.d.ts +9 -5
- package/dist/server/response-observer-runtime.d.ts.map +1 -1
- package/dist/server/response-observer-runtime.js +10 -6
- package/dist/server/response-observer-runtime.js.map +1 -1
- package/dist/server/runtime-core.d.ts +2 -0
- package/dist/server/runtime-core.d.ts.map +1 -1
- package/dist/server/runtime-core.js.map +1 -1
- package/dist/server/server-types.d.ts +53 -3
- package/dist/server/server-types.d.ts.map +1 -1
- package/dist/server/server.d.ts +191 -19
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +1206 -218
- package/dist/server/server.js.map +1 -1
- package/dist/server/sse.d.ts.map +1 -1
- package/dist/server/sse.js +10 -7
- package/dist/server/sse.js.map +1 -1
- package/dist/server/static-headers.d.ts +3 -0
- package/dist/server/static-headers.d.ts.map +1 -1
- package/dist/server/static-headers.js +19 -1
- package/dist/server/static-headers.js.map +1 -1
- package/dist/server/transport-lane.d.ts +8 -0
- package/dist/server/transport-lane.d.ts.map +1 -1
- package/dist/server/transport-lane.js +9 -8
- package/dist/server/transport-lane.js.map +1 -1
- package/dist/server/validation.d.ts.map +1 -1
- package/dist/server/validation.js +5 -1
- package/dist/server/validation.js.map +1 -1
- package/dist/server/websocket.d.ts +10 -3
- package/dist/server/websocket.d.ts.map +1 -1
- package/dist/server/websocket.js +7 -4
- package/dist/server/websocket.js.map +1 -1
- package/dist/server/ws-bun.d.ts +2 -0
- package/dist/server/ws-bun.d.ts.map +1 -1
- package/dist/server/ws-bun.js +6 -3
- package/dist/server/ws-bun.js.map +1 -1
- package/dist/server/ws-hook.d.ts +2 -1
- package/dist/server/ws-hook.d.ts.map +1 -1
- package/dist/server.d.ts +7 -4
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +4 -1
- package/dist/server.js.map +1 -1
- package/dist/single-copy-register.js +5 -0
- package/dist/single-copy-register.js.map +1 -1
- package/dist/single-copy.d.ts +46 -3
- package/dist/single-copy.d.ts.map +1 -1
- package/dist/single-copy.js +125 -12
- package/dist/single-copy.js.map +1 -1
- package/dist/tool-contract.d.ts +23 -0
- package/dist/tool-contract.d.ts.map +1 -1
- package/dist/tool-contract.js +37 -3
- package/dist/tool-contract.js.map +1 -1
- package/dist/transport-codec.d.ts.map +1 -1
- package/dist/transport-codec.js +2 -1
- package/dist/transport-codec.js.map +1 -1
- package/package.json +27 -2
- package/src/assurance.ts +52 -2
- package/src/capabilities.ts +81 -22
- package/src/diff.ts +31 -6
- package/src/durable-adapters.ts +20 -3
- package/src/durable-effect.ts +10 -3
- package/src/edge-kit.ts +7 -0
- package/src/effect-lifecycle.ts +5 -1
- package/src/effect-scope.ts +2 -1
- package/src/errors.ts +3 -0
- package/src/evidence.ts +237 -12
- package/src/idempotency.ts +72 -8
- package/src/index.ts +40 -3
- package/src/internal/code-unit-order.ts +8 -0
- package/src/internal/durable-saga-protocol.ts +2 -0
- package/src/internal/route-catalog.ts +19 -18
- package/src/internal/route-compiler.ts +62 -13
- package/src/internal/route-execution.ts +13 -3
- package/src/internal/route-lanes.ts +37 -28
- package/src/internal/route-program.ts +434 -20
- package/src/ledger.ts +11 -5
- package/src/manifest.ts +41 -36
- package/src/mount.ts +62 -0
- package/src/reflection.ts +84 -1
- package/src/response-observer.ts +1 -0
- package/src/router/overlap.ts +258 -0
- package/src/router/pattern.ts +349 -25
- package/src/router/router.ts +76 -51
- package/src/server/auth.ts +75 -0
- package/src/server/bare-error-lane.ts +12 -11
- package/src/server/body-lane.ts +46 -6
- package/src/server/body-parser.ts +241 -0
- package/src/server/body.ts +82 -9
- package/src/server/client-ip.ts +9 -0
- package/src/server/context.ts +219 -12
- package/src/server/contract.ts +54 -9
- package/src/server/core-copies.ts +21 -0
- package/src/server/edge.ts +2 -0
- package/src/server/http.ts +34 -0
- package/src/server/idempotency-lane.ts +147 -72
- package/src/server/install.ts +6 -0
- package/src/server/methods.ts +143 -0
- package/src/server/multipart.ts +424 -0
- package/src/server/node-outcome-hook.ts +40 -7
- package/src/server/not-found-answer.ts +109 -0
- package/src/server/not-found.ts +121 -0
- package/src/server/plugin.ts +45 -1
- package/src/server/proto-guard.ts +128 -9
- package/src/server/query.ts +49 -0
- package/src/server/registry.ts +17 -2
- package/src/server/request-context.ts +44 -16
- package/src/server/request-lineage.ts +15 -0
- package/src/server/request-target.ts +46 -0
- package/src/server/respond.ts +14 -3
- package/src/server/response-contract-lane.ts +97 -16
- package/src/server/response-observer-runtime.ts +37 -8
- package/src/server/runtime-core.ts +2 -0
- package/src/server/server-types.ts +59 -2
- package/src/server/server.ts +1613 -269
- package/src/server/sse.ts +10 -8
- package/src/server/static-headers.ts +22 -1
- package/src/server/transport-lane.ts +17 -9
- package/src/server/validation.ts +7 -1
- package/src/server/websocket.ts +23 -9
- package/src/server/ws-bun.ts +8 -3
- package/src/server/ws-hook.ts +2 -1
- package/src/server.ts +38 -2
- package/src/single-copy-register.ts +5 -0
- package/src/single-copy.ts +160 -12
- package/src/tool-contract.ts +66 -3
- package/src/transport-codec.ts +2 -1
package/dist/server/server.js
CHANGED
|
@@ -7,31 +7,38 @@ import { compileRouteOptions } from "../internal/route-compiler.js";
|
|
|
7
7
|
import { compileRouteExecutionPlan, } from "../internal/route-execution.js";
|
|
8
8
|
import { compileRouteProgram, executeRouteProgram } from "../internal/route-program.js";
|
|
9
9
|
import { isSameOriginRequest } from "../internal/same-origin.js";
|
|
10
|
-
import { decodeRouteParams } from "../router/pattern.js";
|
|
11
|
-
import { EMPTY_PARAMS, Router } from "../router/router.js";
|
|
10
|
+
import { decodeRouteParams, expandOptionalParams } from "../router/pattern.js";
|
|
11
|
+
import { EMPTY_PARAMS, METHODS, Router } from "../router/router.js";
|
|
12
12
|
import { emitRequestErrorLog, renderBareError } from "./bare-error-lane.js";
|
|
13
13
|
import { assertByteLimit, markTransportCap, markTrustedBodyFraming, UNLIMITED_BODY_BYTES, } from "./body.js";
|
|
14
|
-
import { resolveClientIp } from "./client-ip.js";
|
|
14
|
+
import { NIFRA_PLATFORM_CLIENT_IP_DERIVED, resolveClientIp, } from "./client-ip.js";
|
|
15
|
+
import "./core-copies.js";
|
|
15
16
|
import { hasLowercaseHeaderKeysMark, headerKeysAllLowercase, markLowercaseHeaderKeys, } from "./header-case.js";
|
|
16
17
|
import { headerObjectOf } from "./headers.js";
|
|
17
|
-
import { jsonError, pathnameOf, plainError, urlPartsOf } from "./http.js";
|
|
18
|
+
import { isRoutableMethod, jsonError, pathnameOf, plainError, urlPartsOf, } from "./http.js";
|
|
18
19
|
import { withStaticNodeHeaders } from "./node-outcome.js";
|
|
19
20
|
import { queryObjectOf, searchOf } from "./query.js";
|
|
20
|
-
import { RequestContext, readBodyFramed } from "./request-context.js";
|
|
21
|
+
import { PLATFORM_PEER, RequestContext, readBodyFramed, } from "./request-context.js";
|
|
22
|
+
import { recordRequestReplacement } from "./request-lineage.js";
|
|
23
|
+
import { hasDotSegment, resolveDotSegments } from "./request-target.js";
|
|
21
24
|
import { applyStaticResponseHeaders, buildStaticResponseHeaders, fusedRespond, fusedRespondNoSet, toResponse, } from "./respond.js";
|
|
22
25
|
import { CONTEXT_SEARCH, CONTEXT_SET, EMPTY_RESPONSE_CONTROLS, getNeverAbortSignal, getUnboundedRequestBudget, headerOf, isResponseResult, requestOf, } from "./runtime-core.js";
|
|
23
26
|
import { normalizeStaticResponseHeaders } from "./static-headers.js";
|
|
24
27
|
import { plainValidationError } from "./validation.js";
|
|
25
|
-
import {
|
|
28
|
+
import { NIFRA_BACKEND_WS_RUNTIME } from "../mount.js";
|
|
29
|
+
import { GET_WS_RUNTIME, INSTALL_EFFECT_LEDGER, INSTALL_IDEMPOTENCY, INSTALL_MCP, INSTALL_NODE_DIRECT, INSTALL_NOT_FOUND, INSTALL_RESPONSE_CONTRACT, INSTALL_RESPONSE_OBSERVER, INSTALL_SSE, INSTALL_WS, NODE_NATIVE_MOUNT, RESOLVE_NODE_MOUNT, } from "./install.js";
|
|
26
30
|
import { jsonLogger } from "./logger.js";
|
|
31
|
+
// CORS uses this marker to say its request hook only handles OPTIONS preflight. Bun can keep
|
|
32
|
+
// ordinary registered routes on its native table and send unmatched OPTIONS requests through the
|
|
33
|
+
// normal fetch fallback where the hook still runs.
|
|
34
|
+
const BUN_NATIVE_REQUEST_SAFE = Symbol.for("@nifrajs/core/bun-native-request-safe");
|
|
27
35
|
const functionToString = Function.prototype.toString;
|
|
28
36
|
const CONTEXTLESS_ARROW = /^(?:async\s*)?\(\s*\)\s*(?::[\s\S]*?)?=>/;
|
|
29
|
-
/** The socket peer Bun observed, as a `Platform` for the request lifecycle (`undefined` if unknown).
|
|
30
|
-
* Typed structurally on `requestIP` alone so any Bun `Server` (WS or not) satisfies it. */
|
|
37
|
+
/** The socket peer Bun observed, as a `Platform` for the request lifecycle (`undefined` if unknown). */
|
|
31
38
|
function bunPeerPlatform(server, req) {
|
|
32
|
-
// Bun's requestIP()
|
|
39
|
+
// Bun's requestIP() costs ~0.5 us per call, about 7% of a bare GET's server time. Keep the
|
|
33
40
|
// documented raw-peer c.clientIp behavior, but resolve it lazily: most routes never read c.clientIp,
|
|
34
|
-
//
|
|
41
|
+
// so they should not pay for the socket lookup on every request. A getter also
|
|
35
42
|
// preserves middleware that inspects the platform argument directly and trust-mode routes, which
|
|
36
43
|
// resolve the value in deriveClientIp before the handler runs.
|
|
37
44
|
let resolved = false;
|
|
@@ -46,8 +53,38 @@ function bunPeerPlatform(server, req) {
|
|
|
46
53
|
},
|
|
47
54
|
};
|
|
48
55
|
}
|
|
56
|
+
/**
|
|
57
|
+
* Whether the portable router gives `other` a request that `route` also matches: the two patterns can
|
|
58
|
+
* match one path, and `other` is the more specific at the first segment where they differ. `route`
|
|
59
|
+
* holds static and `:param` segments only; `other` has a wildcard or a part-literal segment.
|
|
60
|
+
*
|
|
61
|
+
* It errs toward `true`. A segment that is part literal, part parameter is taken to match whatever
|
|
62
|
+
* `route` has in that position.
|
|
63
|
+
*/
|
|
64
|
+
function outranks(other, route) {
|
|
65
|
+
let ahead = false;
|
|
66
|
+
for (let i = 0; i < route.length; i++) {
|
|
67
|
+
const theirs = other[i];
|
|
68
|
+
if (theirs === undefined)
|
|
69
|
+
return false;
|
|
70
|
+
if (theirs.kind === "wildcard")
|
|
71
|
+
return ahead;
|
|
72
|
+
const ours = route[i];
|
|
73
|
+
if (ours.kind === "static") {
|
|
74
|
+
if (theirs.kind === "static" ? theirs.value !== ours.value : !ahead)
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
else if (theirs.kind !== "param")
|
|
78
|
+
ahead = true;
|
|
79
|
+
}
|
|
80
|
+
return ahead && other.length === route.length;
|
|
81
|
+
}
|
|
49
82
|
const WS_PASS = { kind: "pass" };
|
|
50
83
|
const DEFAULT_WS_UPGRADE_TIMEOUT_MS = 10_000;
|
|
84
|
+
/** Counts `ws()` routes and `mount()`s across every app in this copy of core: the only
|
|
85
|
+
* changes that can make a mounted app take WebSocket upgrades. An app's answer to that question
|
|
86
|
+
* (`mountsTakeUpgrades`) is kept until this moves. */
|
|
87
|
+
let wsChanges = 0;
|
|
51
88
|
/** `app.ws()` (and everything downstream of it) needs the runtime `@nifrajs/core/ws` registers. */
|
|
52
89
|
function requireWsRuntime(runtime) {
|
|
53
90
|
if (runtime === undefined) {
|
|
@@ -88,13 +125,27 @@ export { plainValidationError } from "./validation.js";
|
|
|
88
125
|
// Query-string + urlencoded-form parsing now lives in `./query.ts`; re-exported so existing
|
|
89
126
|
// importers keep resolving `searchOf`/`queryObjectOf`/`QueryValue` from here.
|
|
90
127
|
export { queryObjectOf, searchOf };
|
|
91
|
-
|
|
128
|
+
/**
|
|
129
|
+
* A param value Bun's native route table matched but the portable router must decide: an invalid
|
|
130
|
+
* escape, or a piece of path Bun matched raw while its own parsed URL says otherwise - a `.` / `..`
|
|
131
|
+
* segment (Bun hands `%2e` over decoded), or a backslash, which the parsed URL reads as `/`.
|
|
132
|
+
*/
|
|
133
|
+
const unroutedParam = (value) => value !== undefined &&
|
|
134
|
+
(value.includes("\uFFFD") || value.includes("\\") || value === "." || value === "..");
|
|
135
|
+
function hasUnroutedParam(params) {
|
|
92
136
|
for (const key in params) {
|
|
93
|
-
if (params[key]
|
|
137
|
+
if (unroutedParam(params[key]))
|
|
94
138
|
return true;
|
|
95
139
|
}
|
|
96
140
|
return false;
|
|
97
141
|
}
|
|
142
|
+
/**
|
|
143
|
+
* Deno is the runtime that hands an app a `Request` whose `url` is the target as the client sent it;
|
|
144
|
+
* Bun, workerd and every `new Request()` parse it first. Only there does `fetch` read the URL up front.
|
|
145
|
+
*/
|
|
146
|
+
const rawRequestTargets = typeof globalThis.Deno !== "undefined";
|
|
147
|
+
/** `req` as Bun or workerd would have delivered it: the same request, its URL's dot segments resolved. */
|
|
148
|
+
const withResolvedTarget = (req) => new Request(resolveDotSegments(req.url), req);
|
|
98
149
|
function normalizeMountPrefix(path) {
|
|
99
150
|
if (!path.startsWith("/") || path.includes("?") || path.includes("#")) {
|
|
100
151
|
throw new TypeError("mountFetch path must be an absolute pathname without a query or hash");
|
|
@@ -112,6 +163,17 @@ function normalizeMountPrefix(path) {
|
|
|
112
163
|
function underMountPrefix(pathname, prefix) {
|
|
113
164
|
return prefix === "/" || pathname === prefix || pathname.startsWith(`${prefix}/`);
|
|
114
165
|
}
|
|
166
|
+
function mountPriorityOf(priority) {
|
|
167
|
+
if (priority === undefined)
|
|
168
|
+
return 0;
|
|
169
|
+
if (!Number.isSafeInteger(priority)) {
|
|
170
|
+
throw new TypeError("mount priority must be a safe integer");
|
|
171
|
+
}
|
|
172
|
+
return priority;
|
|
173
|
+
}
|
|
174
|
+
function compareMounts(a, b) {
|
|
175
|
+
return b.priority - a.priority || b.path.length - a.path.length || a.order - b.order;
|
|
176
|
+
}
|
|
115
177
|
function stripMountPrefix(request, prefix) {
|
|
116
178
|
if (prefix === "/")
|
|
117
179
|
return request;
|
|
@@ -140,6 +202,68 @@ function isContextlessNoArgArrow(handler) {
|
|
|
140
202
|
* these renders happen where no request context exists - before routing, or after it was abandoned.
|
|
141
203
|
*/
|
|
142
204
|
const webResponseOf = (result) => result instanceof Response ? result : toResponse(result, EMPTY_RESPONSE_CONTROLS);
|
|
205
|
+
/** A `group(prefix)` hook's scope: every request whose path is the prefix or lies under it - served,
|
|
206
|
+
* 404, 405, or preflight alike (Hono's `use("/api/*")` reach). The router matches static segments
|
|
207
|
+
* byte-exactly and case-sensitively, with the same path scanner used here, so no spelling a group
|
|
208
|
+
* route answers to can fall outside this test. */
|
|
209
|
+
const underPrefix = (prefix) => {
|
|
210
|
+
const nested = `${prefix}/`;
|
|
211
|
+
return (req) => {
|
|
212
|
+
const path = pathnameOf(req.url);
|
|
213
|
+
return path === prefix || path.startsWith(nested);
|
|
214
|
+
};
|
|
215
|
+
};
|
|
216
|
+
/** A merged server's hook scope: exactly the routes MERGED, snapshotted into a dedicated matcher so
|
|
217
|
+
* the guard never reflects whatever the source's own catalog grows into afterwards. */
|
|
218
|
+
const servedBy = (routes) => {
|
|
219
|
+
const scope = new RouteCatalog();
|
|
220
|
+
scope.addBatch(routes);
|
|
221
|
+
return (req) => scope.find(req.method, pathnameOf(req.url)).found;
|
|
222
|
+
};
|
|
223
|
+
/** Gate adopted hooks to `inScope` (`undefined`: adopt them unchanged). The request is a request
|
|
224
|
+
* hook's first argument (`reqAt` 0) and every response-side hook's second; out of scope a hook
|
|
225
|
+
* declines, except a Web response hook, which must hand the response on (`passFirst`). An
|
|
226
|
+
* `undefined` slot marks an unpaired Node twin, position-aligned with its Web list, and stays
|
|
227
|
+
* `undefined` - wrapping it would fabricate a twin that never existed. */
|
|
228
|
+
const scopeHooks = (hooks, inScope, reqAt, passFirst) => inScope === undefined
|
|
229
|
+
? hooks
|
|
230
|
+
: hooks.map((hook) => hook === undefined
|
|
231
|
+
? hook
|
|
232
|
+
: ((first, second) => inScope(reqAt === 0 ? first : second)
|
|
233
|
+
? hook(first, second)
|
|
234
|
+
: passFirst && first));
|
|
235
|
+
/** A `group()` prefix: `/`-led segments of RFC 3986 `pchar` minus `%` and `:`, none of them `.` or
|
|
236
|
+
* `..`. That is text the router compares byte-for-byte as static, so a prefix can never smuggle a
|
|
237
|
+
* param, a wildcard, or an escape, and never spells a path (dot or empty segment, trailing slash)
|
|
238
|
+
* that no normalized client URL reaches. Segments are delimited by a `/` the class excludes, so the
|
|
239
|
+
* match is linear. */
|
|
240
|
+
const GROUP_PREFIX = /^(?:\/(?!\.\.?(?:\/|$))[\w.~!$&'()+,;=@-]+)+$/;
|
|
241
|
+
/** The order-scoped chain a group scope inherits - copied, so what the group adds stays its own. */
|
|
242
|
+
const GROUP_CHAIN = [
|
|
243
|
+
"derives",
|
|
244
|
+
"authStages",
|
|
245
|
+
"beforeHandleHooks",
|
|
246
|
+
"afterHandleHooks",
|
|
247
|
+
"onErrorHooks",
|
|
248
|
+
"aroundHooks",
|
|
249
|
+
"activeAssurance",
|
|
250
|
+
// Inherited so a group route computes `authenticated` (idempotency principal scoping) exactly as a
|
|
251
|
+
// parent route would; only the evidence the group adds is folded at adoption.
|
|
252
|
+
"globalAssurance",
|
|
253
|
+
"capabilityInterceptors",
|
|
254
|
+
"capabilityObservers",
|
|
255
|
+
];
|
|
256
|
+
/** Installed runtimes: a group scope inherits them, and adoption hoists a source's into a server that
|
|
257
|
+
* has none, so composing servers cannot silently disable a safety lane. */
|
|
258
|
+
const SERVER_RUNTIMES = [
|
|
259
|
+
"responseContractRuntime",
|
|
260
|
+
"idempotencyRuntime",
|
|
261
|
+
"effectLedgerRuntime",
|
|
262
|
+
"mcpRuntime",
|
|
263
|
+
"nodeOutcomeRuntime",
|
|
264
|
+
"sseRuntime",
|
|
265
|
+
"wsRuntime",
|
|
266
|
+
];
|
|
143
267
|
// Stable module-level finalizers so `fetch`/`resolveNode` allocate no per-request closures.
|
|
144
268
|
const IDENTITY_RESPONSE = (response) => webResponseOf(response);
|
|
145
269
|
const RESPONSE_TIMEOUT = () => jsonError(503, "request_timeout");
|
|
@@ -232,6 +356,10 @@ export class Server {
|
|
|
232
356
|
/** WebSocket routes, matched separately at upgrade time (a GET + `Upgrade: websocket`). */
|
|
233
357
|
wsRouter;
|
|
234
358
|
wsRouteCount;
|
|
359
|
+
/** `wsChanges` when `wsMountsLive` was last worked out; -1 until then. */
|
|
360
|
+
wsMountsCheckedAt = -1;
|
|
361
|
+
/** Whether an app mounted here takes WebSocket upgrades, as of `wsMountsCheckedAt`. */
|
|
362
|
+
wsMountsLive = false;
|
|
235
363
|
/** In-process pub/sub backing `ws.subscribe(topic)` + `app.publish(topic, data)` (single-instance).
|
|
236
364
|
* Created by the first `app.ws()` via the `@nifrajs/core/ws` runtime - `undefined` until then, so a
|
|
237
365
|
* no-WebSocket app never constructs (or bundles) it. */
|
|
@@ -241,7 +369,8 @@ export class Server {
|
|
|
241
369
|
* rather than delegate to Bun's native `server.publish`. */
|
|
242
370
|
wsHasValidatedSend = false;
|
|
243
371
|
/** Bun's native topic broadcast, bound by `listen()` in native-pubsub mode; `app.publish` uses it
|
|
244
|
-
* instead of the JS registry. `undefined` off Bun, before `listen()`,
|
|
372
|
+
* instead of the JS registry. `undefined` off Bun, before `listen()`, with a validated-send route,
|
|
373
|
+
* or when a mounted app takes upgrades. */
|
|
245
374
|
nativePublish;
|
|
246
375
|
maxBodyBytes;
|
|
247
376
|
protoPoisoning;
|
|
@@ -261,6 +390,9 @@ export class Server {
|
|
|
261
390
|
gracefulSignals;
|
|
262
391
|
stopHooks;
|
|
263
392
|
fetchMounts;
|
|
393
|
+
mountOrder;
|
|
394
|
+
/** Number of composed mounts that must run before the parent route table. Keeps legacy mounts off the hot path. */
|
|
395
|
+
preRouteMountCount;
|
|
264
396
|
/** Capacity-admission gate; `undefined` = off (the request path pays nothing). */
|
|
265
397
|
capacityGate;
|
|
266
398
|
onCapabilityUse;
|
|
@@ -274,6 +406,8 @@ export class Server {
|
|
|
274
406
|
idempotencyRuntime;
|
|
275
407
|
/** Installed opt-in runtime for `.tool()`/`.resource()`/`.prompt()`; `undefined` until `.use(mcp())`. */
|
|
276
408
|
mcpRuntime;
|
|
409
|
+
/** Installed answer for a request no route matched; `undefined` (the plain 404) until `.use(notFound())`. */
|
|
410
|
+
notFoundLane;
|
|
277
411
|
/** Installed Node-direct renderer for direct `resolveNode()` callers; `undefined` until `.use(nodeDirect())`. */
|
|
278
412
|
nodeOutcomeRuntime;
|
|
279
413
|
/** Installed streaming runtime for `.sse()` routes; `undefined` until `.use(streaming())`. */
|
|
@@ -288,6 +422,8 @@ export class Server {
|
|
|
288
422
|
bunServer;
|
|
289
423
|
sealed;
|
|
290
424
|
derives;
|
|
425
|
+
/** Dedicated authentication stages captured by subsequent routes. */
|
|
426
|
+
authStages;
|
|
291
427
|
decorations;
|
|
292
428
|
beforeHandleHooks;
|
|
293
429
|
afterHandleHooks;
|
|
@@ -297,10 +433,14 @@ export class Server {
|
|
|
297
433
|
onNodeRequestHooks;
|
|
298
434
|
/** Registration-time eligibility for the Node request twin lane. */
|
|
299
435
|
nodeRequestHooksComplete;
|
|
436
|
+
/** True when every request hook is safe to skip on a Bun native route. */
|
|
437
|
+
bunNativeRequestHooksSafe;
|
|
300
438
|
onResponseHooks;
|
|
301
439
|
onNodeResponseHooks;
|
|
302
440
|
/** Registration-time eligibility for the Node response twin lane. */
|
|
303
441
|
nodeResponseHooksComplete;
|
|
442
|
+
/** True when every response hook only mutates headers and can run after a Bun fused response. */
|
|
443
|
+
bunNativeResponseHeadersOnly;
|
|
304
444
|
/**
|
|
305
445
|
* True once a bundle registers a RAW `onNodeResponse` twin - one handed the outcome's header
|
|
306
446
|
* record itself rather than the case-normalizing view. Their names are contractually the wire
|
|
@@ -329,7 +469,7 @@ export class Server {
|
|
|
329
469
|
responseSources;
|
|
330
470
|
/** Memoized NodeRequestContext per plain-`Request` source - see {@link nodeRequestContextOf}. */
|
|
331
471
|
nodeContexts;
|
|
332
|
-
/**
|
|
472
|
+
/** Plugins/middleware already applied via `use`, by name - for idempotent dedupe. */
|
|
333
473
|
appliedPlugins;
|
|
334
474
|
/** Order-scoped evidence captured by routes registered after an assured plugin. */
|
|
335
475
|
activeAssurance;
|
|
@@ -338,7 +478,14 @@ export class Server {
|
|
|
338
478
|
/** App-declared MCP resources / prompts (via {@link resource} / {@link prompt}), read by `nifra mcp`. */
|
|
339
479
|
mcpResourceList;
|
|
340
480
|
mcpPromptList;
|
|
481
|
+
/** Kept so a {@link group} scope is built with exactly this server's settings: fused lanes read
|
|
482
|
+
* several of them at request time, so a group route must never run under different ones. */
|
|
483
|
+
options;
|
|
484
|
+
/** Static prefix joined onto every route this server registers; non-empty only on a group scope. */
|
|
485
|
+
routePrefix;
|
|
341
486
|
constructor(options = {}) {
|
|
487
|
+
this.options = options;
|
|
488
|
+
this.routePrefix = "";
|
|
342
489
|
this.catalog = new RouteCatalog();
|
|
343
490
|
this.wsRouter = new Router();
|
|
344
491
|
this.wsRouteCount = 0;
|
|
@@ -372,6 +519,8 @@ export class Server {
|
|
|
372
519
|
this.gracefulSignals = options.gracefulSignals ?? false;
|
|
373
520
|
this.stopHooks = [];
|
|
374
521
|
this.fetchMounts = [];
|
|
522
|
+
this.mountOrder = 0;
|
|
523
|
+
this.preRouteMountCount = 0;
|
|
375
524
|
this.capacityGate = options.admission;
|
|
376
525
|
this.onCapabilityUse = options.onCapabilityUse;
|
|
377
526
|
this.capabilityInterceptors = [];
|
|
@@ -394,6 +543,7 @@ export class Server {
|
|
|
394
543
|
beginHookAudit(this, options.unusedScopedHooks ?? "warn", options.logger !== undefined);
|
|
395
544
|
}
|
|
396
545
|
this.derives = [];
|
|
546
|
+
this.authStages = [];
|
|
397
547
|
this.decorations = {};
|
|
398
548
|
this.beforeHandleHooks = [];
|
|
399
549
|
this.afterHandleHooks = [];
|
|
@@ -402,9 +552,11 @@ export class Server {
|
|
|
402
552
|
this.onRequestHooks = [];
|
|
403
553
|
this.onNodeRequestHooks = [];
|
|
404
554
|
this.nodeRequestHooksComplete = true;
|
|
555
|
+
this.bunNativeRequestHooksSafe = true;
|
|
405
556
|
this.onResponseHooks = [];
|
|
406
557
|
this.onNodeResponseHooks = [];
|
|
407
558
|
this.nodeResponseHooksComplete = true;
|
|
559
|
+
this.bunNativeResponseHeadersOnly = true;
|
|
408
560
|
this.hasRawNodeResponseHook = false;
|
|
409
561
|
this.onResponseFinalizedHooks = [];
|
|
410
562
|
this.staticResponseHeaders = undefined;
|
|
@@ -415,7 +567,7 @@ export class Server {
|
|
|
415
567
|
this.responseRequests = new WeakMap();
|
|
416
568
|
this.responseSources = new WeakMap();
|
|
417
569
|
this.nodeContexts = new WeakMap();
|
|
418
|
-
this.appliedPlugins = new
|
|
570
|
+
this.appliedPlugins = new Map();
|
|
419
571
|
this.activeAssurance = [];
|
|
420
572
|
this.globalAssurance = [];
|
|
421
573
|
this.mcpResourceList = [];
|
|
@@ -423,7 +575,7 @@ export class Server {
|
|
|
423
575
|
}
|
|
424
576
|
assertConfigurable(operation) {
|
|
425
577
|
if (this.sealed) {
|
|
426
|
-
throw new FrameworkError("SERVER_SEALED", `server configuration is sealed after listen(); call ${operation} before
|
|
578
|
+
throw new FrameworkError("SERVER_SEALED", `server configuration is sealed after listen() (a group once its builder returns); call ${operation} before`);
|
|
427
579
|
}
|
|
428
580
|
}
|
|
429
581
|
/** Register a callback awaited after graceful drain and server stop. Hung callbacks are bounded. */
|
|
@@ -447,9 +599,70 @@ export class Server {
|
|
|
447
599
|
path: normalizeMountPrefix(path),
|
|
448
600
|
handler: handler,
|
|
449
601
|
stripPrefix: options.stripPrefix === true,
|
|
602
|
+
priority: mountPriorityOf(options.priority),
|
|
603
|
+
fallbackOn404: options.fallbackOn === 404,
|
|
604
|
+
beforeRoutes: false,
|
|
605
|
+
order: this.mountOrder++,
|
|
606
|
+
opaque: options.opaque,
|
|
607
|
+
};
|
|
608
|
+
this.fetchMounts.push(mount);
|
|
609
|
+
this.fetchMounts.sort(compareMounts);
|
|
610
|
+
return this;
|
|
611
|
+
}
|
|
612
|
+
/**
|
|
613
|
+
* Compose a child fetch app under a literal path prefix.
|
|
614
|
+
*
|
|
615
|
+
* Composed mounts run after the app-global request phase and before the parent's route table. This
|
|
616
|
+
* makes them safe to use in an SSR shell whose `/*` page route would otherwise swallow an API mount.
|
|
617
|
+
* If the child exposes `resolveWebSocketUpgrade`, the same mount also participates in adapter-owned
|
|
618
|
+
* WebSocket upgrades; the serving adapter still upgrades its original socket, while the child sees
|
|
619
|
+
* the mounted request path (stripped when requested).
|
|
620
|
+
*
|
|
621
|
+
* `fallbackOn: 404` is deliberately limited to GET/HEAD/OPTIONS at dispatch time. Those methods are
|
|
622
|
+
* replayable without buffering an untrusted request body; POST/PUT/PATCH and streaming requests
|
|
623
|
+
* never get retried after a child has had a chance to consume them.
|
|
624
|
+
*/
|
|
625
|
+
mount(options) {
|
|
626
|
+
this.assertConfigurable("mount()");
|
|
627
|
+
if (options === null || typeof options !== "object") {
|
|
628
|
+
throw new TypeError("mount() requires { path, app }");
|
|
629
|
+
}
|
|
630
|
+
const app = options.app;
|
|
631
|
+
if (app === null || typeof app !== "object") {
|
|
632
|
+
throw new TypeError("mount() app must be an object with fetch()");
|
|
633
|
+
}
|
|
634
|
+
if (typeof app.fetch !== "function") {
|
|
635
|
+
throw new TypeError("mount() app.fetch must be a function");
|
|
636
|
+
}
|
|
637
|
+
const resolver = typeof app.resolveWebSocketUpgrade === "function"
|
|
638
|
+
? app.resolveWebSocketUpgrade.bind(app)
|
|
639
|
+
: undefined;
|
|
640
|
+
// Bun needs a websocket callback table in the parent process. A composed Nifra app names the
|
|
641
|
+
// runtime its upgrades need through this internal seam, which `listen()` asks (see
|
|
642
|
+
// `[GET_WS_RUNTIME]`); a resolver without it names none. Adapters that wire standard sockets use
|
|
643
|
+
// the resolver outcome's own `attach`.
|
|
644
|
+
const getRuntime = app[GET_WS_RUNTIME] ?? app[NIFRA_BACKEND_WS_RUNTIME];
|
|
645
|
+
const mount = {
|
|
646
|
+
path: normalizeMountPrefix(options.path),
|
|
647
|
+
handler: app.fetch.bind(app),
|
|
648
|
+
...(resolver === undefined
|
|
649
|
+
? {}
|
|
650
|
+
: {
|
|
651
|
+
resolveWebSocketUpgrade: resolver,
|
|
652
|
+
wsRuntime: typeof getRuntime === "function" ? getRuntime.bind(app) : () => null,
|
|
653
|
+
}),
|
|
654
|
+
stripPrefix: options.stripPrefix === true,
|
|
655
|
+
priority: mountPriorityOf(options.priority),
|
|
656
|
+
fallbackOn404: options.fallbackOn === 404,
|
|
657
|
+
beforeRoutes: true,
|
|
658
|
+
order: this.mountOrder++,
|
|
659
|
+
opaque: options.opaque,
|
|
660
|
+
app,
|
|
450
661
|
};
|
|
451
662
|
this.fetchMounts.push(mount);
|
|
452
|
-
this.fetchMounts.sort(
|
|
663
|
+
this.fetchMounts.sort(compareMounts);
|
|
664
|
+
this.preRouteMountCount += 1;
|
|
665
|
+
wsChanges += 1;
|
|
453
666
|
return this;
|
|
454
667
|
}
|
|
455
668
|
/**
|
|
@@ -475,6 +688,30 @@ export class Server {
|
|
|
475
688
|
* by none before; one added after the last route reaches nothing
|
|
476
689
|
* (see {@link ServerOptions.unusedScopedHooks}).
|
|
477
690
|
*/
|
|
691
|
+
/**
|
|
692
|
+
* Register a dedicated authentication stage for subsequent routes.
|
|
693
|
+
*
|
|
694
|
+
* Unlike `derive`, this stage receives a body-blind authentication capsule and is compiled before
|
|
695
|
+
* route validation. Keeping it separate prevents an unrelated derive from accidentally becoming a
|
|
696
|
+
* security gate and lets the route compiler retain a protected fast lane.
|
|
697
|
+
*/
|
|
698
|
+
authenticate(stage) {
|
|
699
|
+
this.assertConfigurable("authenticate()");
|
|
700
|
+
if (stage === null || typeof stage !== "object") {
|
|
701
|
+
throw new TypeError("authenticate() requires an authentication stage");
|
|
702
|
+
}
|
|
703
|
+
if (typeof stage.id !== "string" || stage.id.trim() === "") {
|
|
704
|
+
throw new TypeError("authenticate() stage id must be a non-empty string");
|
|
705
|
+
}
|
|
706
|
+
if (typeof stage.run !== "function") {
|
|
707
|
+
throw new TypeError("authenticate() stage run must be a function");
|
|
708
|
+
}
|
|
709
|
+
if (stage.mode !== undefined && stage.mode !== "sync" && stage.mode !== "async") {
|
|
710
|
+
throw new TypeError('authenticate() stage mode must be "sync" or "async"');
|
|
711
|
+
}
|
|
712
|
+
this.authStages.push(stage);
|
|
713
|
+
return this;
|
|
714
|
+
}
|
|
478
715
|
decorate(key, value) {
|
|
479
716
|
this.assertConfigurable("decorate()");
|
|
480
717
|
this.decorations[key] = value;
|
|
@@ -491,6 +728,7 @@ export class Server {
|
|
|
491
728
|
this.onRequestHooks.push(fn);
|
|
492
729
|
this.onNodeRequestHooks.push(undefined);
|
|
493
730
|
this.nodeRequestHooksComplete = false;
|
|
731
|
+
this.bunNativeRequestHooksSafe = false;
|
|
494
732
|
return this;
|
|
495
733
|
}
|
|
496
734
|
/**
|
|
@@ -575,6 +813,7 @@ export class Server {
|
|
|
575
813
|
this.onResponseHooks.push(fn);
|
|
576
814
|
this.onNodeResponseHooks.push(undefined);
|
|
577
815
|
this.nodeResponseHooksComplete = false;
|
|
816
|
+
this.bunNativeResponseHeadersOnly = false;
|
|
578
817
|
return this;
|
|
579
818
|
}
|
|
580
819
|
/**
|
|
@@ -591,7 +830,8 @@ export class Server {
|
|
|
591
830
|
* app.responseHeaders({ "x-frame-options": "DENY", "referrer-policy": "no-referrer" })
|
|
592
831
|
*
|
|
593
832
|
* They are DEFAULTS: a value the request itself produced (`c.set.headers`, or a response hook)
|
|
594
|
-
* wins, whatever casing it used.
|
|
833
|
+
* wins, whatever casing it used. In a group, a name the group declares replaces the value its
|
|
834
|
+
* enclosing scopes declared before the group, on the group's routes. Names are lowercased once here; a non-string value, an invalid
|
|
595
835
|
* name, `__proto__`, or a name the render owns (`content-type`, `content-length`,
|
|
596
836
|
* `transfer-encoding`, `set-cookie`) throws a `TypeError` at wire-up.
|
|
597
837
|
*
|
|
@@ -623,7 +863,7 @@ export class Server {
|
|
|
623
863
|
// serialized-body marker survives for later `onResponseBody`/`onResponseRaw` observers and the
|
|
624
864
|
// Node writer; reconstructing a `Response` here would strip that marker and reclassify the Node
|
|
625
865
|
// outcome from `json` to a generic `response`. A guarded foreign response takes its clone path.
|
|
626
|
-
const statics = buildStaticResponseHeaders(record);
|
|
866
|
+
const statics = buildStaticResponseHeaders(record, this.inheritedStatics);
|
|
627
867
|
this.onResponseHooks.push((response) => applyStaticResponseHeaders(response, statics));
|
|
628
868
|
this.onNodeResponseHooks.push(undefined);
|
|
629
869
|
this.nodeResponseHooksComplete = false;
|
|
@@ -632,7 +872,7 @@ export class Server {
|
|
|
632
872
|
const merged = this.staticResponseHeaders === undefined
|
|
633
873
|
? record
|
|
634
874
|
: { ...this.staticResponseHeaders.record, ...record };
|
|
635
|
-
this.staticResponseHeaders = buildStaticResponseHeaders(merged);
|
|
875
|
+
this.staticResponseHeaders = buildStaticResponseHeaders(merged, this.inheritedStatics);
|
|
636
876
|
const statics = this.staticResponseHeaders;
|
|
637
877
|
this.wrapWebResponse = (response) => applyStaticResponseHeaders(webResponseOf(response), statics);
|
|
638
878
|
this.webResponseTimeout = () => applyStaticResponseHeaders(RESPONSE_TIMEOUT(), statics);
|
|
@@ -660,24 +900,36 @@ export class Server {
|
|
|
660
900
|
this.onResponseFinalizedHooks.push(fn);
|
|
661
901
|
return this;
|
|
662
902
|
}
|
|
663
|
-
use(
|
|
903
|
+
use(input) {
|
|
904
|
+
// A method-routes plugin is an ordinary plugin function; only its type is opaque.
|
|
905
|
+
const arg = input;
|
|
664
906
|
this.assertConfigurable("use()");
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
if (name
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
907
|
+
const name = typeof arg === "function" ? arg.pluginName : arg.name;
|
|
908
|
+
if (name !== undefined) {
|
|
909
|
+
if (this.appliedPlugins.has(name)) {
|
|
910
|
+
const parent = this.inheritedPlugins?.get(name);
|
|
911
|
+
// A group's builder passing another instance of a plugin its parent applied would have it
|
|
912
|
+
// dropped, configuration and all. A plugin's own dependency use() shares the parent's copy.
|
|
913
|
+
if (parent !== undefined && parent !== arg) {
|
|
914
|
+
throw new RouteConfigError("PLUGIN_RECONFIGURED", `use() of ${JSON.stringify(name)} in a group: its parent applied another instance`);
|
|
915
|
+
}
|
|
916
|
+
return this; // idempotent: already applied
|
|
671
917
|
}
|
|
918
|
+
this.appliedPlugins.set(name, arg);
|
|
919
|
+
}
|
|
920
|
+
if (typeof arg === "function") {
|
|
672
921
|
const evidence = assuranceDeclarationsOf(arg);
|
|
673
922
|
const pluginOnly = evidence.filter((item) => item.scope === "plugin");
|
|
674
923
|
this.globalAssurance.push(...evidence.filter((item) => item.scope === "global"));
|
|
675
924
|
this.activeAssurance.push(...evidence.filter((item) => item.scope === "subsequent"));
|
|
676
925
|
this.activeAssurance.push(...pluginOnly);
|
|
926
|
+
const inherited = this.inheritedPlugins;
|
|
927
|
+
this.inheritedPlugins = undefined;
|
|
677
928
|
try {
|
|
678
929
|
return arg(this);
|
|
679
930
|
}
|
|
680
931
|
finally {
|
|
932
|
+
this.inheritedPlugins = inherited;
|
|
681
933
|
// Remove only this plugin's temporary evidence. Nested assured plugins may deliberately leave
|
|
682
934
|
// subsequent evidence active, so truncating the whole array would lose real ordering semantics.
|
|
683
935
|
for (const item of pluginOnly) {
|
|
@@ -687,11 +939,6 @@ export class Server {
|
|
|
687
939
|
}
|
|
688
940
|
}
|
|
689
941
|
}
|
|
690
|
-
if (arg.name !== undefined) {
|
|
691
|
-
if (this.appliedPlugins.has(arg.name))
|
|
692
|
-
return this;
|
|
693
|
-
this.appliedPlugins.add(arg.name);
|
|
694
|
-
}
|
|
695
942
|
const evidence = assuranceDeclarationsOf(arg);
|
|
696
943
|
if (evidence.some((item) => item.scope === "plugin")) {
|
|
697
944
|
throw new Error('route assurance: scope "plugin" may only annotate a plugin function');
|
|
@@ -713,6 +960,9 @@ export class Server {
|
|
|
713
960
|
this.onNodeRequestHooks.push(arg.onNodeRequest);
|
|
714
961
|
if (arg.onNodeRequest === undefined)
|
|
715
962
|
this.nodeRequestHooksComplete = false;
|
|
963
|
+
if (!arg[BUN_NATIVE_REQUEST_SAFE]) {
|
|
964
|
+
this.bunNativeRequestHooksSafe = false;
|
|
965
|
+
}
|
|
716
966
|
}
|
|
717
967
|
else if (arg.onNodeRequest !== undefined) {
|
|
718
968
|
throw new TypeError("onNodeRequest() requires a paired onRequest() hook");
|
|
@@ -731,6 +981,7 @@ export class Server {
|
|
|
731
981
|
this.nodeResponseHooksComplete = false;
|
|
732
982
|
else
|
|
733
983
|
this.hasRawNodeResponseHook = true;
|
|
984
|
+
this.bunNativeResponseHeadersOnly = false;
|
|
734
985
|
}
|
|
735
986
|
else if (arg.onNodeResponse !== undefined) {
|
|
736
987
|
throw new TypeError("onNodeResponse() requires a paired onResponse() hook");
|
|
@@ -739,10 +990,18 @@ export class Server {
|
|
|
739
990
|
// defaults its hook may then override, which is the order a single bundle reads in.
|
|
740
991
|
if (arg.responseHeaders !== undefined)
|
|
741
992
|
this.responseHeaders(arg.responseHeaders);
|
|
742
|
-
if (arg.onResponseHeaders !== undefined)
|
|
743
|
-
this.responseObserverMethods().onResponseHeaders(arg.onResponseHeaders);
|
|
744
|
-
|
|
745
|
-
|
|
993
|
+
if (arg.onResponseHeaders !== undefined) {
|
|
994
|
+
this.responseObserverMethods().onResponseHeaders(arg.onResponseHeaders, arg.onNodeResponseHeaders);
|
|
995
|
+
}
|
|
996
|
+
else if (arg.onNodeResponseHeaders !== undefined) {
|
|
997
|
+
throw new TypeError("onNodeResponseHeaders() requires a paired onResponseHeaders() hook");
|
|
998
|
+
}
|
|
999
|
+
if (arg.onResponseBody !== undefined) {
|
|
1000
|
+
this.responseObserverMethods().onResponseBody(arg.onResponseBody, arg.onNodeResponseBody);
|
|
1001
|
+
}
|
|
1002
|
+
else if (arg.onNodeResponseBody !== undefined) {
|
|
1003
|
+
throw new TypeError("onNodeResponseBody() requires a paired onResponseBody() hook");
|
|
1004
|
+
}
|
|
746
1005
|
if (arg.onResponseRaw !== undefined)
|
|
747
1006
|
this.responseObserverMethods().onResponseRaw(arg.onResponseRaw);
|
|
748
1007
|
if (arg.onResponseFinalized !== undefined)
|
|
@@ -861,10 +1120,11 @@ export class Server {
|
|
|
861
1120
|
this.topics ??= runtime.createTopics();
|
|
862
1121
|
// A `messageSchema` wraps `message` with validation once, here - every adapter then dispatches
|
|
863
1122
|
// already-validated, typed messages (Bun/Deno/Node/Workers) with no per-adapter code.
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
1123
|
+
const entry = { handler: runtime.wrapHandler(handler, this.protoPoisoning) };
|
|
1124
|
+
for (const form of expandOptionalParams(path))
|
|
1125
|
+
this.wsRouter.add("GET", form, entry);
|
|
867
1126
|
this.wsRouteCount += 1;
|
|
1127
|
+
wsChanges += 1;
|
|
868
1128
|
return this;
|
|
869
1129
|
}
|
|
870
1130
|
/**
|
|
@@ -904,16 +1164,37 @@ export class Server {
|
|
|
904
1164
|
* is the "compiled", order-scoped per-route chain.
|
|
905
1165
|
*/
|
|
906
1166
|
register(method, path, schema, handler) {
|
|
907
|
-
this.
|
|
908
|
-
this.catalog.add(this.prepareRoute(method, path, schema, handler));
|
|
1167
|
+
this.registerBatch([{ method, path, schema, handler }]);
|
|
909
1168
|
}
|
|
910
1169
|
/** Register a contract/group route batch atomically. Every route captures the same current chain it
|
|
911
|
-
* would capture through {@link register}; no route becomes visible unless the full batch validates.
|
|
1170
|
+
* would capture through {@link register}; no route becomes visible unless the full batch validates.
|
|
1171
|
+
*
|
|
1172
|
+
* A path ending in optional parameters (`/users/:id?`) is one catalog route per concrete pattern,
|
|
1173
|
+
* so reflection, OpenAPI, evidence and the typed client all see ordinary routes. */
|
|
912
1174
|
registerBatch(routes) {
|
|
913
1175
|
this.assertConfigurable("route registration");
|
|
914
|
-
const staged =
|
|
1176
|
+
const staged = [];
|
|
1177
|
+
for (const { method, path, schema, handler } of routes) {
|
|
1178
|
+
for (const form of expandOptionalParams(this.prefixed(path))) {
|
|
1179
|
+
staged.push(this.prepareRoute(method, form, schema, handler));
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
915
1182
|
this.catalog.addBatch(staged);
|
|
916
1183
|
}
|
|
1184
|
+
/** Join a group scope's prefix BEFORE compilation, so the pattern, the reflected descriptor, the
|
|
1185
|
+
* capability guard, the ledger record, and assurance path matching all see the one served path. */
|
|
1186
|
+
prefixed(path) {
|
|
1187
|
+
const prefix = this.routePrefix;
|
|
1188
|
+
if (prefix === "")
|
|
1189
|
+
return path;
|
|
1190
|
+
if (path === "/")
|
|
1191
|
+
return prefix;
|
|
1192
|
+
// Checked here, not left to the pattern compiler: `"/api" + "users"` is a valid-looking path
|
|
1193
|
+
// (`/apiusers`) that would silently escape the group instead of failing.
|
|
1194
|
+
if (path[0] === "/")
|
|
1195
|
+
return prefix + path;
|
|
1196
|
+
throw new RouteConfigError("INVALID_PATH", `group(${JSON.stringify(prefix)}) route path must start with "/": ${JSON.stringify(path)}`);
|
|
1197
|
+
}
|
|
917
1198
|
prepareRoute(method, path, schema, handler) {
|
|
918
1199
|
const compiled = compileRouteOptions({
|
|
919
1200
|
maxBodyBytes: this.maxBodyBytes,
|
|
@@ -927,6 +1208,7 @@ export class Server {
|
|
|
927
1208
|
idempotencyRuntime: this.idempotencyRuntime,
|
|
928
1209
|
responseContractRuntime: this.responseContractRuntime,
|
|
929
1210
|
derives: this.derives,
|
|
1211
|
+
authStages: this.authStages,
|
|
930
1212
|
beforeHandleHooks: this.beforeHandleHooks,
|
|
931
1213
|
afterHandleHooks: this.afterHandleHooks,
|
|
932
1214
|
onErrorHooks: this.onErrorHooks,
|
|
@@ -934,16 +1216,30 @@ export class Server {
|
|
|
934
1216
|
defaultOnValidationError: this.defaultOnValidationError,
|
|
935
1217
|
}, method, path, schema, handler);
|
|
936
1218
|
const { pattern, bodyLimit, capabilities, routeDecorations, hasDecorations, idempotent, ledgered, responseContract, lanes, routeAssurance, } = compiled;
|
|
1219
|
+
if (idempotent !== undefined && this.idempotencyRuntime !== undefined) {
|
|
1220
|
+
handler = this.idempotencyRuntime.wrapHandler(handler);
|
|
1221
|
+
}
|
|
937
1222
|
const { bare, fusedQuery, fusedBody } = lanes;
|
|
938
|
-
const fusedBodyRunner = fusedBody
|
|
939
|
-
? this.buildFusedBodyRunner(handler, schema?.body, hasDecorations ? routeDecorations : undefined, bodyLimit ?? UNLIMITED_BODY_BYTES)
|
|
940
|
-
: undefined;
|
|
941
1223
|
// Fused lifecycle lanes: derive + before (with or without an after), and body + the same shape.
|
|
942
1224
|
// The lane selectors in `selectRouteLanes` are exhaustive about the lifecycleHookLane /
|
|
943
1225
|
// body-derive-before-after eligibility, so we only need to check the lane and the hook counts.
|
|
944
1226
|
const lifecycleHookLane = lanes.lifecycleHookLane;
|
|
945
1227
|
const isFusedLifecycle = lifecycleHookLane !== undefined && this.derives.length === 1;
|
|
946
1228
|
const isFusedBodyLifecycle = lanes.fusedLane === "body-derive-before" || lanes.fusedLane === "body-derive-before-after";
|
|
1229
|
+
// The generic runners below are the Node-direct twins of the fused Web lanes built further
|
|
1230
|
+
// down: same single-frame lifecycle, finalizing through the caller's own `finalize` instead of
|
|
1231
|
+
// building a `Response`, so the `!webFast` dispatcher prefers them over the generic program
|
|
1232
|
+
// exactly as it prefers the body-only runner. Only the original body-only lane may feed the Web
|
|
1233
|
+
// wrapper; every other runner here preserves its derive/before/after stages.
|
|
1234
|
+
const fusedBodyRunner = fusedBody
|
|
1235
|
+
? this.buildFusedBodyRunner(handler, schema?.body, hasDecorations ? routeDecorations : undefined, bodyLimit ?? UNLIMITED_BODY_BYTES)
|
|
1236
|
+
: isFusedBodyLifecycle
|
|
1237
|
+
? this.buildFusedBodyDeriveBeforeAfterRunner(handler, this.derives[0], this.beforeHandleHooks[0], this.afterHandleHooks[0], schema?.body, bodyLimit ?? UNLIMITED_BODY_BYTES)
|
|
1238
|
+
: isFusedLifecycle && lifecycleHookLane === "derive-before"
|
|
1239
|
+
? this.buildFusedDeriveBeforeRunner(handler, this.derives[0], this.beforeHandleHooks[0], undefined, schema?.query, bodyLimit ?? UNLIMITED_BODY_BYTES)
|
|
1240
|
+
: isFusedLifecycle && lifecycleHookLane === "derive-before-after"
|
|
1241
|
+
? this.buildFusedDeriveBeforeRunner(handler, this.derives[0], this.beforeHandleHooks[0], this.afterHandleHooks[0], schema?.query, bodyLimit ?? UNLIMITED_BODY_BYTES)
|
|
1242
|
+
: undefined;
|
|
947
1243
|
const fusedWeb = bare && this.aroundHooks.length === 0
|
|
948
1244
|
? this.buildFusedWeb(handler, hasDecorations ? routeDecorations : undefined, isContextlessNoArgArrow(handler), bodyLimit ?? UNLIMITED_BODY_BYTES)
|
|
949
1245
|
: fusedQuery
|
|
@@ -961,6 +1257,7 @@ export class Server {
|
|
|
961
1257
|
schema,
|
|
962
1258
|
handler: handler,
|
|
963
1259
|
derives: this.derives,
|
|
1260
|
+
authStages: compiled.authStages,
|
|
964
1261
|
beforeHandle: this.beforeHandleHooks,
|
|
965
1262
|
afterHandle: this.afterHandleHooks,
|
|
966
1263
|
onError: this.onErrorHooks,
|
|
@@ -988,6 +1285,7 @@ export class Server {
|
|
|
988
1285
|
ledgered,
|
|
989
1286
|
responseContract,
|
|
990
1287
|
derives: [...this.derives],
|
|
1288
|
+
authStages: [...compiled.authStages],
|
|
991
1289
|
decorations: routeDecorations,
|
|
992
1290
|
hasDecorations,
|
|
993
1291
|
beforeHandle: [...this.beforeHandleHooks],
|
|
@@ -1002,6 +1300,9 @@ export class Server {
|
|
|
1002
1300
|
method,
|
|
1003
1301
|
path,
|
|
1004
1302
|
schema,
|
|
1303
|
+
...(responseContract === undefined
|
|
1304
|
+
? {}
|
|
1305
|
+
: { responseContract: responseContract.runtime.mode }),
|
|
1005
1306
|
...(capabilities.length > 0 ? { capabilities } : {}),
|
|
1006
1307
|
...(schema?.family === true ? { family: true } : {}),
|
|
1007
1308
|
};
|
|
@@ -1029,11 +1330,13 @@ export class Server {
|
|
|
1029
1330
|
this.assertConfigurable("responseObserver()");
|
|
1030
1331
|
const methods = runtime.install({
|
|
1031
1332
|
assertConfigurable: (operation) => this.assertConfigurable(operation),
|
|
1032
|
-
addResponseHook: (web, node) => {
|
|
1333
|
+
addResponseHook: (web, node, headerOnly = false) => {
|
|
1033
1334
|
this.onResponseHooks.push(web);
|
|
1034
1335
|
this.onNodeResponseHooks.push(node);
|
|
1035
1336
|
if (node === undefined)
|
|
1036
1337
|
this.nodeResponseHooksComplete = false;
|
|
1338
|
+
if (!headerOnly)
|
|
1339
|
+
this.bunNativeResponseHeadersOnly = false;
|
|
1037
1340
|
},
|
|
1038
1341
|
enableResponseBodyTagging: () => {
|
|
1039
1342
|
if (this.responseBodyTag === undefined) {
|
|
@@ -1057,6 +1360,11 @@ export class Server {
|
|
|
1057
1360
|
this.assertConfigurable("mcp()");
|
|
1058
1361
|
this.mcpRuntime = runtime;
|
|
1059
1362
|
}
|
|
1363
|
+
/** @internal Symbol-keyed install seam for the `notFound()` plugin. Off the public typed surface. */
|
|
1364
|
+
[INSTALL_NOT_FOUND](lane) {
|
|
1365
|
+
this.assertConfigurable("notFound()");
|
|
1366
|
+
this.notFoundLane = lane;
|
|
1367
|
+
}
|
|
1060
1368
|
/** @internal Symbol-keyed install seam for the `nodeDirect()` plugin. Off the public typed surface. */
|
|
1061
1369
|
[INSTALL_NODE_DIRECT](runtime) {
|
|
1062
1370
|
this.assertConfigurable("nodeDirect()");
|
|
@@ -1072,6 +1380,41 @@ export class Server {
|
|
|
1072
1380
|
this.assertConfigurable("websocket()");
|
|
1073
1381
|
this.wsRuntime = runtime;
|
|
1074
1382
|
}
|
|
1383
|
+
/**
|
|
1384
|
+
* @internal The runtime Bun serves this app's WebSocket upgrades with, asked by `listen()` and by a
|
|
1385
|
+
* parent that mounts this app: `undefined` while nothing here can take one, `null` when something
|
|
1386
|
+
* can but no runtime is at hand. A mounted Nifra app answers through this same seam, so mounting an
|
|
1387
|
+
* app without a WebSocket route needs no runtime; a resolver without the seam cannot say, so it
|
|
1388
|
+
* counts. `seen` ends a mount cycle (an app mounted under itself to alias a prefix).
|
|
1389
|
+
*/
|
|
1390
|
+
[GET_WS_RUNTIME](seen) {
|
|
1391
|
+
return this.wsRouteCount > 0 ? this.wsRuntime : this.mountedWsRuntime(seen);
|
|
1392
|
+
}
|
|
1393
|
+
/** The `[GET_WS_RUNTIME]` answer for the apps mounted here alone, whatever this app's own routes. */
|
|
1394
|
+
mountedWsRuntime(seen = new Set()) {
|
|
1395
|
+
if (seen.has(this))
|
|
1396
|
+
return;
|
|
1397
|
+
seen.add(this);
|
|
1398
|
+
let found;
|
|
1399
|
+
for (const mount of this.fetchMounts) {
|
|
1400
|
+
const runtime = mount.wsRuntime?.(seen);
|
|
1401
|
+
if (runtime === undefined)
|
|
1402
|
+
continue;
|
|
1403
|
+
found = this.wsRuntime ?? runtime;
|
|
1404
|
+
if (found)
|
|
1405
|
+
return found;
|
|
1406
|
+
}
|
|
1407
|
+
return found;
|
|
1408
|
+
}
|
|
1409
|
+
/** Whether an app mounted here takes WebSocket upgrades. Worked out again only after a `ws()` or a
|
|
1410
|
+
* `mount()` somewhere, so the upgrade check on a plain request costs one comparison. */
|
|
1411
|
+
mountsTakeUpgrades() {
|
|
1412
|
+
if (this.wsMountsCheckedAt !== wsChanges) {
|
|
1413
|
+
this.wsMountsLive = this.mountedWsRuntime() !== undefined;
|
|
1414
|
+
this.wsMountsCheckedAt = wsChanges;
|
|
1415
|
+
}
|
|
1416
|
+
return this.wsMountsLive;
|
|
1417
|
+
}
|
|
1075
1418
|
/**
|
|
1076
1419
|
* Merge another server's routes into this one - the composition escape hatch for large apps.
|
|
1077
1420
|
*
|
|
@@ -1098,75 +1441,188 @@ export class Server {
|
|
|
1098
1441
|
*/
|
|
1099
1442
|
merge(other) {
|
|
1100
1443
|
this.assertConfigurable("merge()");
|
|
1444
|
+
if (!(other instanceof Server)) {
|
|
1445
|
+
// A server from another copy of this package reaches this copy's private request state through
|
|
1446
|
+
// symbols it does not share, and would fail per request. The boot warning names the copies.
|
|
1447
|
+
throw new TypeError("merge() requires a server() from this copy of @nifrajs/core");
|
|
1448
|
+
}
|
|
1449
|
+
if (this.routePrefix !== "") {
|
|
1450
|
+
// The merged routes were compiled under their own paths; adopting them here would publish them
|
|
1451
|
+
// OUTSIDE the prefix while looking like part of the group.
|
|
1452
|
+
throw new RouteConfigError("INVALID_PATH", "merge() inside a group cannot prefix compiled routes - merge on the parent");
|
|
1453
|
+
}
|
|
1101
1454
|
const source = other;
|
|
1102
1455
|
if (source.wsRouteCount > 0) {
|
|
1103
1456
|
throw new RouteConfigError("INVALID_PATH", "merge() does not carry WebSocket routes - register .ws() routes on the parent server");
|
|
1104
1457
|
}
|
|
1458
|
+
this.adopt(source, undefined, 0);
|
|
1459
|
+
return this;
|
|
1460
|
+
}
|
|
1461
|
+
/**
|
|
1462
|
+
* Declare routes under a static path prefix, with the prefix in the typed client, `routes()`,
|
|
1463
|
+
* OpenAPI, capability events, and the effect ledger - the counterpart of Hono's `route()`,
|
|
1464
|
+
* Elysia's `group()`, and Fastify's `register({ prefix })`.
|
|
1465
|
+
*
|
|
1466
|
+
* app
|
|
1467
|
+
* .derive(session)
|
|
1468
|
+
* .group("/admin", (admin) =>
|
|
1469
|
+
* admin.beforeHandle(requireAdmin).get("/users", listUsers).get("/users/:id", getUser),
|
|
1470
|
+
* )
|
|
1471
|
+
* .get("/", home) // not covered by requireAdmin
|
|
1472
|
+
*
|
|
1473
|
+
* The builder receives a scope that INHERITS this server's route chain as it stands at the call
|
|
1474
|
+
* (`derive`/`decorate`/`authenticate`/`beforeHandle`/`afterHandle`/`onError`/`around`, assurance,
|
|
1475
|
+
* capability interceptors, installed runtimes, applied plugins), exactly as a route declared here
|
|
1476
|
+
* would. A plugin this server already applied is in effect for the group: the group's `use()` of
|
|
1477
|
+
* that same instance does nothing, and its `use()` of another instance under the same name throws a
|
|
1478
|
+
* `RouteConfigError` (`PLUGIN_RECONFIGURED`), because that instance could never replace the parent's.
|
|
1479
|
+
* A plugin the group applies still shares the parent's copy of a dependency it `use()`s itself.
|
|
1480
|
+
* What it adds stays in the group: its chain additions apply only to its own routes, and its
|
|
1481
|
+
* request/response hooks (`onRequest`, `onResponse`, `responseHeaders`, `onResponseFinalized`, and
|
|
1482
|
+
* middleware bundles that use them) run only for requests whose path is the prefix or below it.
|
|
1483
|
+
* Per-route authorization belongs in `authenticate`/`beforeHandle`, which are compiled into each
|
|
1484
|
+
* route; request hooks are path prefilters.
|
|
1485
|
+
*
|
|
1486
|
+
* The prefix is static text - `"/api"`, `"/api/v1"` - validated when declared: no params,
|
|
1487
|
+
* wildcards, empty or dot segments, percent-escapes, or trailing slash. A group's `"/"` route
|
|
1488
|
+
* serves the prefix itself. Groups nest (`"/api"` then `"/v1"` serves `/api/v1/...`).
|
|
1489
|
+
*
|
|
1490
|
+
* Fail closed: the builder must synchronously return the scope it was given; a collision with an
|
|
1491
|
+
* existing route throws `RouteConfigError` and adds none of the group's routes; `.ws()`, mounts,
|
|
1492
|
+
* MCP `.tool()`s, and `merge()` inside a group are refused (register them on the parent). Once the
|
|
1493
|
+
* builder returns, the scope is closed - a leaked reference cannot add routes later.
|
|
1494
|
+
*/
|
|
1495
|
+
group(prefix, build) {
|
|
1496
|
+
this.assertConfigurable("group()");
|
|
1497
|
+
if (typeof prefix !== "string" || !GROUP_PREFIX.test(prefix)) {
|
|
1498
|
+
throw new RouteConfigError("INVALID_PATH", `group() prefix must be a static path like "/api/v1": ${JSON.stringify(prefix)}`);
|
|
1499
|
+
}
|
|
1500
|
+
const fullPrefix = this.routePrefix + prefix;
|
|
1501
|
+
const scope = new Server(this.options);
|
|
1502
|
+
scope.routePrefix = fullPrefix;
|
|
1503
|
+
scope.inheritedStatics = { ...this.inheritedStatics, ...this.staticResponseHeaders?.record };
|
|
1504
|
+
for (const key of GROUP_CHAIN)
|
|
1505
|
+
scope[key].push(...this[key]);
|
|
1506
|
+
for (const key of SERVER_RUNTIMES)
|
|
1507
|
+
scope[key] = this[key];
|
|
1508
|
+
Object.assign(scope.decorations, this.decorations);
|
|
1509
|
+
// A plugin already applied here is already in effect for the group (its chain was copied above
|
|
1510
|
+
// and its request hooks are app-wide), so re-applying it would run it twice per request. The
|
|
1511
|
+
// group's own plugins are NOT copied back: they are scoped, and a parent `use()` of the same
|
|
1512
|
+
// plugin must still apply it to the parent's routes. The response observer is the exception: it
|
|
1513
|
+
// installs methods bound to the server it is applied to, so the scope must be free to install its
|
|
1514
|
+
// own (re-applying it registers no hook, so nothing can run twice).
|
|
1515
|
+
for (const [name, plugin] of this.appliedPlugins) {
|
|
1516
|
+
if (name !== "nifra:response-observer")
|
|
1517
|
+
scope.appliedPlugins.set(name, plugin);
|
|
1518
|
+
}
|
|
1519
|
+
scope.inheritedPlugins = new Map(scope.appliedPlugins);
|
|
1520
|
+
let built;
|
|
1521
|
+
try {
|
|
1522
|
+
built = build(scope);
|
|
1523
|
+
// Only `listen()` seals a server, so a scope sealed here was served on its own - without this
|
|
1524
|
+
// server's request hooks - which a group must never be.
|
|
1525
|
+
if (scope.sealed)
|
|
1526
|
+
built = undefined;
|
|
1527
|
+
}
|
|
1528
|
+
finally {
|
|
1529
|
+
// Closed on every exit, including a throw: an async continuation or a leaked reference must
|
|
1530
|
+
// not register routes that nothing will ever adopt.
|
|
1531
|
+
scope.sealed = true;
|
|
1532
|
+
}
|
|
1533
|
+
const label = `group(${JSON.stringify(fullPrefix)})`;
|
|
1534
|
+
if (built !== scope) {
|
|
1535
|
+
throw new TypeError(`${label} builder must return its group synchronously`);
|
|
1536
|
+
}
|
|
1537
|
+
if (scope.wsRouteCount > 0 ||
|
|
1538
|
+
scope.fetchMounts.length > 0 ||
|
|
1539
|
+
scope.catalog.entries().some((route) => route.descriptor.tool !== undefined)) {
|
|
1540
|
+
throw new RouteConfigError("INVALID_PATH", `${label} cannot hold ws(), mount, or MCP tool routes - add them to the parent`);
|
|
1541
|
+
}
|
|
1542
|
+
if (hookAuditRuntime && process.env.NODE_ENV !== "production")
|
|
1543
|
+
sealHookAudit(scope, scope.catalog.size, this.logger);
|
|
1544
|
+
this.adopt(scope, fullPrefix, this.globalAssurance.length);
|
|
1545
|
+
return this;
|
|
1546
|
+
}
|
|
1547
|
+
/**
|
|
1548
|
+
* Take over another server's routes and hooks - the shared core of {@link merge} and {@link group}.
|
|
1549
|
+
* `prefix` selects group semantics: hooks are scoped to requests under the prefix (response hooks
|
|
1550
|
+
* included), and the source's global assurance past `inheritedGlobal` (what it copied from this
|
|
1551
|
+
* server) is folded into its routes. Without a prefix, merge semantics: request hooks are scoped to
|
|
1552
|
+
* the source's routes when it has any, response hooks are appended app-wide.
|
|
1553
|
+
*/
|
|
1554
|
+
adopt(source, prefix, inheritedGlobal) {
|
|
1105
1555
|
const sourceRoutes = source.catalog.entries();
|
|
1106
1556
|
// Hooks and global assurance stay LOCAL to a group that has routes; a route-less group is a
|
|
1107
1557
|
// middleware bundle whose hooks can only mean app-wide intent, so it keeps the global append.
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1558
|
+
// A prefix group is always local: its hooks mean "under this path", routes or not.
|
|
1559
|
+
const scoped = prefix !== undefined || sourceRoutes.length > 0;
|
|
1560
|
+
const ownGlobal = source.globalAssurance.slice(inheritedGlobal);
|
|
1561
|
+
// A group's global assurance rides its (scoped) hooks: folded into each adopted route's own
|
|
1562
|
+
// evidence rather than the parent's global list, so `routes()` never claims the group's
|
|
1111
1563
|
// enforcement for parent routes the group's hooks do not see.
|
|
1112
|
-
const foldedAssurance = scoped &&
|
|
1564
|
+
const foldedAssurance = scoped && ownGlobal.length > 0
|
|
1113
1565
|
? (route) => ({
|
|
1114
1566
|
...route,
|
|
1115
|
-
assurance: [...route.assurance, ...
|
|
1567
|
+
assurance: [...route.assurance, ...ownGlobal],
|
|
1116
1568
|
})
|
|
1117
1569
|
: (route) => route;
|
|
1118
1570
|
this.catalog.addBatch(sourceRoutes.map((route) => this.bindFusedRuntime(foldedAssurance(route))));
|
|
1119
1571
|
// Resolved idempotency/ledger route entries carry their own store/sink configuration, while the
|
|
1120
|
-
// runtime object supplies the generic execution machinery.
|
|
1121
|
-
//
|
|
1122
|
-
//
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
const scope = new RouteCatalog();
|
|
1136
|
-
scope.addBatch(sourceRoutes);
|
|
1137
|
-
this.onRequestHooks.push(...source.onRequestHooks.map((hook) => (req, platform) => scope.find(req.method, pathnameOf(req.url)).found ? hook(req, platform) : undefined));
|
|
1138
|
-
// An `undefined` slot marks an unpaired hook (position-aligned with `onRequestHooks`) and
|
|
1139
|
-
// must stay `undefined` - wrapping it would fabricate a Node twin that never existed.
|
|
1140
|
-
this.onNodeRequestHooks.push(...source.onNodeRequestHooks.map((hook) => hook === undefined
|
|
1141
|
-
? undefined
|
|
1142
|
-
: (req, platform) => scope.find(req.method, pathnameOf(req.url)).found ? hook(req, platform) : undefined));
|
|
1143
|
-
}
|
|
1144
|
-
else {
|
|
1145
|
-
this.onRequestHooks.push(...source.onRequestHooks);
|
|
1146
|
-
this.onNodeRequestHooks.push(...source.onNodeRequestHooks);
|
|
1147
|
-
}
|
|
1572
|
+
// runtime object supplies the generic execution machinery. If the parent already has a runtime,
|
|
1573
|
+
// either implementation can execute every resolved entry because route-specific options were
|
|
1574
|
+
// pinned during registration.
|
|
1575
|
+
const runtimes = this;
|
|
1576
|
+
for (const key of SERVER_RUNTIMES)
|
|
1577
|
+
runtimes[key] ??= source[key];
|
|
1578
|
+
// Which requests a scoped hook sees: under the prefix for a group; for a merged server with
|
|
1579
|
+
// routes, a snapshot of exactly the routes MERGED (not whatever its own catalog grows into later).
|
|
1580
|
+
const inScope = prefix !== undefined
|
|
1581
|
+
? underPrefix(prefix)
|
|
1582
|
+
: scoped && source.onRequestHooks.length > 0
|
|
1583
|
+
? servedBy(sourceRoutes)
|
|
1584
|
+
: undefined;
|
|
1585
|
+
this.onRequestHooks.push(...scopeHooks(source.onRequestHooks, inScope, 0));
|
|
1586
|
+
this.onNodeRequestHooks.push(...scopeHooks(source.onNodeRequestHooks, inScope, 0));
|
|
1148
1587
|
this.nodeRequestHooksComplete &&= source.nodeRequestHooksComplete;
|
|
1149
|
-
|
|
1150
|
-
//
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1588
|
+
this.bunNativeRequestHooksSafe &&= source.bunNativeRequestHooksSafe;
|
|
1589
|
+
// A merged server's response hooks are app-wide; a group's run only under its prefix.
|
|
1590
|
+
const responseScope = prefix === undefined ? undefined : inScope;
|
|
1591
|
+
const statics = source.staticResponseHeaders;
|
|
1592
|
+
if (statics !== undefined) {
|
|
1593
|
+
if (responseScope === undefined) {
|
|
1594
|
+
// The group's static declarations came before its own response hooks, so they are folded in
|
|
1595
|
+
// first - and fold themselves into a hook here if this server already has one (same ordering
|
|
1596
|
+
// rule as a direct `responseHeaders()` call).
|
|
1597
|
+
this.addStaticResponseHeaders({ ...statics.record });
|
|
1598
|
+
}
|
|
1599
|
+
else {
|
|
1600
|
+
// The static tier is folded into EVERY response this server builds, so a group's static
|
|
1601
|
+
// headers cannot join it. A hook ahead of the group's own (the sealed scope is discarded
|
|
1602
|
+
// after adoption) keeps their meaning - defaults a value already on the response wins over.
|
|
1603
|
+
source.onResponseHooks.unshift((response) => applyStaticResponseHeaders(response, statics));
|
|
1604
|
+
source.onNodeResponseHooks.unshift(undefined);
|
|
1605
|
+
source.nodeResponseHooksComplete = false;
|
|
1606
|
+
}
|
|
1607
|
+
}
|
|
1608
|
+
this.onResponseHooks.push(...scopeHooks(source.onResponseHooks, responseScope, 1, true));
|
|
1609
|
+
this.onNodeResponseHooks.push(...scopeHooks(source.onNodeResponseHooks, responseScope, 1));
|
|
1610
|
+
this.onResponseFinalizedHooks.push(...scopeHooks(source.onResponseFinalizedHooks, responseScope, 1));
|
|
1157
1611
|
this.nodeResponseHooksComplete &&= source.nodeResponseHooksComplete;
|
|
1612
|
+
this.bunNativeResponseHeadersOnly &&= source.bunNativeResponseHeadersOnly;
|
|
1158
1613
|
this.hasRawNodeResponseHook ||= source.hasRawNodeResponseHook;
|
|
1159
|
-
this.onResponseFinalizedHooks.push(...source.onResponseFinalizedHooks);
|
|
1160
1614
|
if (source.responseBodyTag !== undefined) {
|
|
1161
1615
|
const owner = this.enableResponseBodyTagging();
|
|
1162
1616
|
this.responseBodyOwners.add(source.responseBodyTag);
|
|
1163
1617
|
source.responseBodyOwners.add(owner);
|
|
1164
1618
|
}
|
|
1165
1619
|
if (!scoped)
|
|
1166
|
-
this.globalAssurance.push(...
|
|
1620
|
+
this.globalAssurance.push(...ownGlobal);
|
|
1621
|
+
// Cleanup the source registered (a pool, a queue consumer) belongs to the server that now runs
|
|
1622
|
+
// its routes; dropping it would leak the resource past `stop()`.
|
|
1623
|
+
this.stopHooks.push(...source.stopHooks);
|
|
1167
1624
|
this.mcpResourceList.push(...source.mcpResourceList);
|
|
1168
1625
|
this.mcpPromptList.push(...source.mcpPromptList);
|
|
1169
|
-
return this;
|
|
1170
1626
|
}
|
|
1171
1627
|
/** A fused renderer closes over runtime services to keep its seven-argument JSC fast path. Merging
|
|
1172
1628
|
* rebinds that closure once to the executing server; generic plans already receive the runtime. */
|
|
@@ -1179,15 +1635,30 @@ export class Server {
|
|
|
1179
1635
|
const lane = entry.execution.fusedLane;
|
|
1180
1636
|
// Type-erase the lane so each `===` branch sees the full union, not the narrowed remainder.
|
|
1181
1637
|
const laneName = lane;
|
|
1182
|
-
// Body+lifecycle routes have a fused Web renderer,
|
|
1183
|
-
//
|
|
1184
|
-
//
|
|
1185
|
-
|
|
1186
|
-
? this.buildFusedBodyRunner(entry.handler, entry.schema?.body, entry.hasDecorations ? entry.decorations : undefined, entry.bodyLimit ?? UNLIMITED_BODY_BYTES)
|
|
1187
|
-
: undefined;
|
|
1638
|
+
// Body+lifecycle routes have a fused Web renderer, and - unlike before - a generic fused
|
|
1639
|
+
// runner too: it preserves derive/before/after (it IS the lifecycle, single-framed), so the
|
|
1640
|
+
// Node-direct dispatcher's preference for the generic slot over the execution plan stays
|
|
1641
|
+
// semantics-preserving. Only the original body-only lane may feed the Web wrapper below.
|
|
1188
1642
|
const derive = entry.derives[0];
|
|
1189
1643
|
const before = entry.beforeHandle[0];
|
|
1190
1644
|
const after = entry.afterHandle[0];
|
|
1645
|
+
const fusedBody = laneName === "body"
|
|
1646
|
+
? this.buildFusedBodyRunner(entry.handler, entry.schema?.body, entry.hasDecorations ? entry.decorations : undefined, entry.bodyLimit ?? UNLIMITED_BODY_BYTES)
|
|
1647
|
+
: laneName === "body-derive-before" && derive !== undefined && before !== undefined
|
|
1648
|
+
? this.buildFusedBodyDeriveBeforeAfterRunner(entry.handler, derive, before, undefined, entry.schema?.body, entry.bodyLimit ?? UNLIMITED_BODY_BYTES)
|
|
1649
|
+
: laneName === "body-derive-before-after" &&
|
|
1650
|
+
derive !== undefined &&
|
|
1651
|
+
before !== undefined &&
|
|
1652
|
+
after !== undefined
|
|
1653
|
+
? this.buildFusedBodyDeriveBeforeAfterRunner(entry.handler, derive, before, after, entry.schema?.body, entry.bodyLimit ?? UNLIMITED_BODY_BYTES)
|
|
1654
|
+
: laneName === "derive-before" && derive !== undefined && before !== undefined
|
|
1655
|
+
? this.buildFusedDeriveBeforeRunner(entry.handler, derive, before, undefined, entry.schema?.query, entry.bodyLimit ?? UNLIMITED_BODY_BYTES)
|
|
1656
|
+
: laneName === "derive-before-after" &&
|
|
1657
|
+
derive !== undefined &&
|
|
1658
|
+
before !== undefined &&
|
|
1659
|
+
after !== undefined
|
|
1660
|
+
? this.buildFusedDeriveBeforeRunner(entry.handler, derive, before, after, entry.schema?.query, entry.bodyLimit ?? UNLIMITED_BODY_BYTES)
|
|
1661
|
+
: undefined;
|
|
1191
1662
|
const fusedWeb = laneName === "body"
|
|
1192
1663
|
? this.buildFusedBodyWeb(fusedBody)
|
|
1193
1664
|
: laneName === "query"
|
|
@@ -1237,6 +1708,10 @@ export class Server {
|
|
|
1237
1708
|
* Bun/Node/Deno omit it (then `c.env` is `undefined` and `c.waitUntil` runs fire-and-forget).
|
|
1238
1709
|
*/
|
|
1239
1710
|
fetch(req, platform) {
|
|
1711
|
+
// Deno's `Request.url` is the target as sent; Bun, workerd and every `new Request()` parse it.
|
|
1712
|
+
// Route what the parsed URL says, so `/users/../admin` is `/admin` on every runtime.
|
|
1713
|
+
if (rawRequestTargets && hasDotSegment(req.url))
|
|
1714
|
+
req = withResolvedTarget(req);
|
|
1240
1715
|
// An edge deployment whose only ingress is this method declares its requests runtime-framed at
|
|
1241
1716
|
// construction; everything else stays on the delivered-byte check (see `trustBodyFraming`).
|
|
1242
1717
|
if (this.trustBodyFraming)
|
|
@@ -1272,13 +1747,14 @@ export class Server {
|
|
|
1272
1747
|
}
|
|
1273
1748
|
/** Web response path when Bun already matched the route. The lifecycle and response hooks remain
|
|
1274
1749
|
* exactly the same as {@link fetchSource}; only portable URL scanning + trie lookup are skipped. */
|
|
1275
|
-
fetchMatched(source, entry, params) {
|
|
1276
|
-
if (this.capacityGate === undefined)
|
|
1277
|
-
return this.fetchMatchedInner(source, entry, params);
|
|
1278
|
-
|
|
1750
|
+
fetchMatched(source, entry, params, platform) {
|
|
1751
|
+
if (this.capacityGate === undefined) {
|
|
1752
|
+
return this.fetchMatchedInner(source, entry, params, platform);
|
|
1753
|
+
}
|
|
1754
|
+
return this.admitGated(requestOf(source), () => this.fetchMatchedInner(source, entry, params, platform));
|
|
1279
1755
|
}
|
|
1280
|
-
fetchMatchedInner(source, entry, params) {
|
|
1281
|
-
const outcome = this.runMatched(source,
|
|
1756
|
+
fetchMatchedInner(source, entry, params, platform) {
|
|
1757
|
+
const outcome = this.runMatched(source, platform, entry, params, undefined, this.finalizeResponse, this.wrapWebResponse, this.webResponseTimeout, true);
|
|
1282
1758
|
if (this.onResponseHooks.length === 0 && this.onResponseFinalizedHooks.length === 0) {
|
|
1283
1759
|
return outcome;
|
|
1284
1760
|
}
|
|
@@ -1344,19 +1820,26 @@ export class Server {
|
|
|
1344
1820
|
}
|
|
1345
1821
|
const parts = source.urlParts ?? urlPartsOf(source.url);
|
|
1346
1822
|
const match = this.catalog.find(source.method, parts.pathname);
|
|
1347
|
-
if (match.found)
|
|
1823
|
+
if (match.found || !isRoutableMethod(source.method))
|
|
1348
1824
|
return undefined;
|
|
1825
|
+
// The mount the ordinary path reaches first: every `mount()` runs before any `mountFetch()`,
|
|
1826
|
+
// whatever their prefix lengths. A 404 fallthrough chain stays on the ordinary path.
|
|
1827
|
+
const mount = this.firstMountUnder(parts.pathname, true) ?? this.firstMountUnder(parts.pathname, false);
|
|
1828
|
+
if (mount === undefined || mount.fallbackOn404)
|
|
1829
|
+
return undefined;
|
|
1830
|
+
const candidate = mount.handler[NODE_NATIVE_MOUNT];
|
|
1831
|
+
return typeof candidate === "function"
|
|
1832
|
+
? {
|
|
1833
|
+
handler: candidate,
|
|
1834
|
+
path: mount.path,
|
|
1835
|
+
stripPrefix: mount.stripPrefix,
|
|
1836
|
+
}
|
|
1837
|
+
: undefined;
|
|
1838
|
+
}
|
|
1839
|
+
firstMountUnder(pathname, beforeRoutes) {
|
|
1349
1840
|
for (const mount of this.fetchMounts) {
|
|
1350
|
-
if (
|
|
1351
|
-
|
|
1352
|
-
const candidate = mount.handler[NODE_NATIVE_MOUNT];
|
|
1353
|
-
return typeof candidate === "function"
|
|
1354
|
-
? {
|
|
1355
|
-
handler: candidate,
|
|
1356
|
-
path: mount.path,
|
|
1357
|
-
stripPrefix: mount.stripPrefix,
|
|
1358
|
-
}
|
|
1359
|
-
: undefined;
|
|
1841
|
+
if (mount.beforeRoutes === beforeRoutes && underMountPrefix(pathname, mount.path))
|
|
1842
|
+
return mount;
|
|
1360
1843
|
}
|
|
1361
1844
|
return undefined;
|
|
1362
1845
|
}
|
|
@@ -1495,6 +1978,7 @@ export class Server {
|
|
|
1495
1978
|
const context = {
|
|
1496
1979
|
status: outcome.status,
|
|
1497
1980
|
headers,
|
|
1981
|
+
headersAreLowercase: !this.hasRawNodeResponseHook && lowercaseKeys,
|
|
1498
1982
|
cookies: outcome.kind === "json" ? outcome.cookies : undefined,
|
|
1499
1983
|
body: outcome.body,
|
|
1500
1984
|
};
|
|
@@ -1612,10 +2096,21 @@ export class Server {
|
|
|
1612
2096
|
}
|
|
1613
2097
|
}
|
|
1614
2098
|
resolveWebSocketUpgrade(req, platform) {
|
|
1615
|
-
|
|
2099
|
+
// Before any header is read: Deno only materializes a request's headers when asked, and that
|
|
2100
|
+
// costs a plain request measurably.
|
|
2101
|
+
if (this.wsRouteCount === 0 && !this.mountsTakeUpgrades())
|
|
1616
2102
|
return WS_PASS;
|
|
1617
2103
|
if (req.headers.get("upgrade")?.toLowerCase() !== "websocket")
|
|
1618
2104
|
return WS_PASS;
|
|
2105
|
+
// A WebSocket handshake is a GET (RFC 6455 section 4.1). Any other method with an Upgrade header
|
|
2106
|
+
// is an ordinary request: its HTTP route answers it, and normal routing refuses an unknown token.
|
|
2107
|
+
if (req.method !== "GET")
|
|
2108
|
+
return WS_PASS;
|
|
2109
|
+
// The handshake routes the path `fetch` would: dot segments resolved (see `fetch`).
|
|
2110
|
+
if (rawRequestTargets && hasDotSegment(req.url))
|
|
2111
|
+
req = withResolvedTarget(req);
|
|
2112
|
+
// The handshake's caller is the one `fetch` derives: hooks and `upgrade()` see the trusted IP.
|
|
2113
|
+
platform = this.trustedPlatform(req, platform);
|
|
1619
2114
|
const timeoutMs = this.wsUpgradeTimeoutMs === 0
|
|
1620
2115
|
? 0
|
|
1621
2116
|
: this.requestTimeoutMs > 0
|
|
@@ -1702,8 +2197,57 @@ export class Server {
|
|
|
1702
2197
|
return { kind: "reject", response: jsonError(500, "internal_error") };
|
|
1703
2198
|
}
|
|
1704
2199
|
};
|
|
2200
|
+
const resolveMounted = (request) => {
|
|
2201
|
+
// Native pub/sub was chosen because no mounted app took upgrades when `listen()` ran. One that
|
|
2202
|
+
// gains a WebSocket route later stays out: its sockets would share this server's Bun topics.
|
|
2203
|
+
if (this.nativePublish !== undefined)
|
|
2204
|
+
return WS_PASS;
|
|
2205
|
+
const pathname = urlPartsOf(request.url).pathname;
|
|
2206
|
+
const dispatch = (start) => {
|
|
2207
|
+
let selected;
|
|
2208
|
+
let selectedIndex = start;
|
|
2209
|
+
for (let i = start; i < this.fetchMounts.length; i++) {
|
|
2210
|
+
const candidate = this.fetchMounts[i];
|
|
2211
|
+
if (candidate.resolveWebSocketUpgrade !== undefined &&
|
|
2212
|
+
underMountPrefix(pathname, candidate.path)) {
|
|
2213
|
+
selected = candidate;
|
|
2214
|
+
selectedIndex = i;
|
|
2215
|
+
break;
|
|
2216
|
+
}
|
|
2217
|
+
}
|
|
2218
|
+
if (selected === undefined)
|
|
2219
|
+
return WS_PASS;
|
|
2220
|
+
const childRequest = selected.stripPrefix
|
|
2221
|
+
? stripMountPrefix(request, selected.path)
|
|
2222
|
+
: request;
|
|
2223
|
+
let child;
|
|
2224
|
+
try {
|
|
2225
|
+
child = selected.resolveWebSocketUpgrade(childRequest, platform);
|
|
2226
|
+
}
|
|
2227
|
+
catch {
|
|
2228
|
+
return { kind: "reject", response: jsonError(500, "internal_error") };
|
|
2229
|
+
}
|
|
2230
|
+
const settle = (outcome) => outcome.kind === "pass" ||
|
|
2231
|
+
(outcome.kind === "reject" && outcome.response.status === 404 && selected.fallbackOn404)
|
|
2232
|
+
? dispatch(selectedIndex + 1)
|
|
2233
|
+
: outcome;
|
|
2234
|
+
return child instanceof Promise ? child.then(settle) : settle(child);
|
|
2235
|
+
};
|
|
2236
|
+
return dispatch(0);
|
|
2237
|
+
};
|
|
2238
|
+
const resolveRoutes = (request) => {
|
|
2239
|
+
const local = resolveMatched(request);
|
|
2240
|
+
if (local instanceof Promise) {
|
|
2241
|
+
return local.then((outcome) => outcome.kind === "pass" ? resolveMounted(request) : outcome);
|
|
2242
|
+
}
|
|
2243
|
+
return local.kind === "pass" ? resolveMounted(request) : local;
|
|
2244
|
+
};
|
|
2245
|
+
const resolveWithDeadline = (request) => {
|
|
2246
|
+
const outcome = resolveRoutes(request);
|
|
2247
|
+
return outcome instanceof Promise ? bounded(outcome, onTimeout) : outcome;
|
|
2248
|
+
};
|
|
1705
2249
|
if (this.onRequestHooks.length === 0)
|
|
1706
|
-
return
|
|
2250
|
+
return resolveWithDeadline(req);
|
|
1707
2251
|
let hooked;
|
|
1708
2252
|
try {
|
|
1709
2253
|
hooked = this.runWebSocketRequestHooks(req, platform);
|
|
@@ -1714,11 +2258,11 @@ export class Server {
|
|
|
1714
2258
|
if (!(hooked instanceof Promise)) {
|
|
1715
2259
|
return hooked instanceof Response
|
|
1716
2260
|
? { kind: "reject", response: hooked }
|
|
1717
|
-
:
|
|
2261
|
+
: resolveWithDeadline(hooked);
|
|
1718
2262
|
}
|
|
1719
2263
|
const pending = hooked.then((result) => result instanceof Response
|
|
1720
2264
|
? { kind: "reject", response: result }
|
|
1721
|
-
:
|
|
2265
|
+
: resolveWithDeadline(result), () => ({ kind: "reject", response: jsonError(500, "internal_error") }));
|
|
1722
2266
|
return bounded(Promise.resolve(pending).then((result) => result), onTimeout);
|
|
1723
2267
|
}
|
|
1724
2268
|
/** Bun `fetch` when WS routes exist: try a WS upgrade first, else run the normal HTTP lifecycle.
|
|
@@ -1730,7 +2274,9 @@ export class Server {
|
|
|
1730
2274
|
return this.fetch(req, bunPeerPlatform(server, req));
|
|
1731
2275
|
if (o.kind === "reject")
|
|
1732
2276
|
return o.response;
|
|
1733
|
-
return server.upgrade(req, {
|
|
2277
|
+
return server.upgrade(req, {
|
|
2278
|
+
data: { handler: o.handler, data: o.data, pubsub: o.pubsub },
|
|
2279
|
+
})
|
|
1734
2280
|
? undefined
|
|
1735
2281
|
: jsonError(426, "upgrade_required");
|
|
1736
2282
|
};
|
|
@@ -1750,6 +2296,12 @@ export class Server {
|
|
|
1750
2296
|
const derived = resolveClientIp(platform?.clientIp, requestOf(source), this.clientIpTrust);
|
|
1751
2297
|
return { ...platform, clientIp: derived };
|
|
1752
2298
|
}
|
|
2299
|
+
trustedPlatform(source, platform) {
|
|
2300
|
+
return this.clientIpTrust === undefined ||
|
|
2301
|
+
platform?.[NIFRA_PLATFORM_CLIENT_IP_DERIVED] === true
|
|
2302
|
+
? platform
|
|
2303
|
+
: this.deriveClientIp(source, platform);
|
|
2304
|
+
}
|
|
1753
2305
|
dispatch(source, platform, finalize, wrapResponse, onTimeout,
|
|
1754
2306
|
// True only from the Web `fetch` path - unlocks each route's fused lane, whose output type IS
|
|
1755
2307
|
// `Response` (`T = Response` there by construction; the node path always passes false).
|
|
@@ -1757,10 +2309,14 @@ export class Server {
|
|
|
1757
2309
|
// Resolve the trust declaration into the platform's `clientIp` ONCE, here at the shared funnel, so
|
|
1758
2310
|
// `c.clientIp` (and every hook/derive downstream) sees the derived caller. No config ⇒ the raw
|
|
1759
2311
|
// socket peer the adapter supplied passes through untouched (a one-property no-op on the hot path).
|
|
1760
|
-
|
|
2312
|
+
// A platform an enclosing nifra server already resolved (an in-process call from an SSR loader)
|
|
2313
|
+
// keeps its `clientIp`: the synthesized request carries none of the visitor's forwarding headers.
|
|
2314
|
+
const resolved = this.trustedPlatform(source, platform);
|
|
1761
2315
|
// onRequest hooks may be async, so a hooked app takes the async path; with no hooks (the common
|
|
1762
2316
|
// case) routing stays synchronous, letting a bare route resolve with no lifecycle promise at all.
|
|
1763
|
-
|
|
2317
|
+
// A token no route can be registered under skips the hooks as well: it cannot match, so routing
|
|
2318
|
+
// answers it 404 or 405 and no hook reads a method string it would misjudge.
|
|
2319
|
+
if (this.onRequestHooks.length === 0 || !isRoutableMethod(source.method)) {
|
|
1764
2320
|
return this.routeAndRun(source, resolved, finalize, wrapResponse, onTimeout, webFast);
|
|
1765
2321
|
}
|
|
1766
2322
|
if (!webFast && this.canUseNodeRequestHooks()) {
|
|
@@ -1816,11 +2372,14 @@ export class Server {
|
|
|
1816
2372
|
this.responseSources.set(source, originalRequest);
|
|
1817
2373
|
let current = source;
|
|
1818
2374
|
for (let i = 0; i < hooks.length; i++) {
|
|
1819
|
-
const
|
|
2375
|
+
const seen = requestOf(current);
|
|
2376
|
+
const outcome = hooks[i](seen, platform);
|
|
1820
2377
|
if (outcome instanceof Promise) {
|
|
1821
2378
|
return outcome.then((early) => this.continueOnRequest(early, i + 1, originalRequest, current, platform, finalize, wrapResponse, onTimeout, webFast));
|
|
1822
2379
|
}
|
|
1823
2380
|
if (outcome instanceof Request) {
|
|
2381
|
+
if (outcome !== seen)
|
|
2382
|
+
recordRequestReplacement(outcome, seen);
|
|
1824
2383
|
current = outcome;
|
|
1825
2384
|
if (outcome !== originalRequest)
|
|
1826
2385
|
this.responseRequests.set(originalRequest, outcome);
|
|
@@ -1835,10 +2394,13 @@ export class Server {
|
|
|
1835
2394
|
* the remaining hooks (awaiting freely - we're already async here). */
|
|
1836
2395
|
async continueOnRequest(first, nextIndex, originalRequest, sourceAtAwait, platform, finalize, wrapResponse, onTimeout, webFast) {
|
|
1837
2396
|
let current = sourceAtAwait;
|
|
2397
|
+
let seen = requestOf(sourceAtAwait);
|
|
1838
2398
|
let early = first;
|
|
1839
2399
|
let index = nextIndex;
|
|
1840
2400
|
for (;;) {
|
|
1841
2401
|
if (early instanceof Request) {
|
|
2402
|
+
if (early !== seen)
|
|
2403
|
+
recordRequestReplacement(early, seen);
|
|
1842
2404
|
current = early;
|
|
1843
2405
|
if (early !== originalRequest)
|
|
1844
2406
|
this.responseRequests.set(originalRequest, early);
|
|
@@ -1848,7 +2410,8 @@ export class Server {
|
|
|
1848
2410
|
}
|
|
1849
2411
|
if (index >= this.onRequestHooks.length)
|
|
1850
2412
|
break;
|
|
1851
|
-
|
|
2413
|
+
seen = requestOf(current);
|
|
2414
|
+
const outcome = this.onRequestHooks[index](seen, platform);
|
|
1852
2415
|
early = outcome instanceof Promise ? await outcome : outcome;
|
|
1853
2416
|
index++;
|
|
1854
2417
|
}
|
|
@@ -1964,9 +2527,20 @@ export class Server {
|
|
|
1964
2527
|
search = searchStart === -1 ? "" : rawUrl.slice(searchStart, searchEnd);
|
|
1965
2528
|
}
|
|
1966
2529
|
}
|
|
2530
|
+
// Explicit composed mounts run before the parent's route table. This is what lets an SSR shell
|
|
2531
|
+
// mount an API at `/api` without its catch-all page route swallowing the request. Legacy
|
|
2532
|
+
// `mountFetch()` stays behind typed routes and is checked in the existing branch below.
|
|
2533
|
+
if (this.preRouteMountCount > 0) {
|
|
2534
|
+
const mounted = this.fetchMount(pathname, source, platform, true);
|
|
2535
|
+
if (mounted !== undefined) {
|
|
2536
|
+
return mounted instanceof Promise
|
|
2537
|
+
? mounted.then((response) => wrapResponse(response))
|
|
2538
|
+
: wrapResponse(mounted);
|
|
2539
|
+
}
|
|
2540
|
+
}
|
|
1967
2541
|
const match = this.catalog.find(source.method, pathname);
|
|
1968
2542
|
if (!match.found) {
|
|
1969
|
-
const mounted = this.fetchMount(pathname, source, platform);
|
|
2543
|
+
const mounted = this.fetchMount(pathname, source, platform, false);
|
|
1970
2544
|
if (mounted !== undefined) {
|
|
1971
2545
|
return mounted instanceof Promise
|
|
1972
2546
|
? mounted.then((response) => wrapResponse(response))
|
|
@@ -1978,7 +2552,10 @@ export class Server {
|
|
|
1978
2552
|
// and every other name in that record is lowercase (the Web lane normalizes either way).
|
|
1979
2553
|
plainError(405, "method_not_allowed", { allow: match.allowed.join(", ") }));
|
|
1980
2554
|
}
|
|
1981
|
-
|
|
2555
|
+
const lane = this.notFoundLane;
|
|
2556
|
+
return lane === undefined
|
|
2557
|
+
? wrapResponse(plainError(404, "not_found"))
|
|
2558
|
+
: lane(this, source, pathname, platform, wrapResponse, onTimeout);
|
|
1982
2559
|
}
|
|
1983
2560
|
// Inspect only captured values for escapes. Scanning the full pathname repeated work the router
|
|
1984
2561
|
// already did and made every plain dynamic route pay for unrelated static path bytes.
|
|
@@ -1988,14 +2565,46 @@ export class Server {
|
|
|
1988
2565
|
}
|
|
1989
2566
|
return this.runMatched(source, platform, match.payload, params, search, finalize, wrapResponse, onTimeout, webFast);
|
|
1990
2567
|
}
|
|
1991
|
-
fetchMount(pathname, source, platform) {
|
|
1992
|
-
|
|
1993
|
-
|
|
2568
|
+
fetchMount(pathname, source, platform, beforeRoutes) {
|
|
2569
|
+
const request = requestOf(source);
|
|
2570
|
+
let first = -1;
|
|
2571
|
+
for (let i = 0; i < this.fetchMounts.length; i++) {
|
|
2572
|
+
const mount = this.fetchMounts[i];
|
|
2573
|
+
if (mount.beforeRoutes !== beforeRoutes || !underMountPrefix(pathname, mount.path))
|
|
1994
2574
|
continue;
|
|
1995
|
-
|
|
1996
|
-
|
|
2575
|
+
first = i;
|
|
2576
|
+
break;
|
|
1997
2577
|
}
|
|
1998
|
-
|
|
2578
|
+
// A mounted handler is arbitrary code that may read the method loosely, so it is only handed a
|
|
2579
|
+
// token a route could be registered under; anything else is left to the route table to refuse.
|
|
2580
|
+
if (first === -1 || !isRoutableMethod(source.method))
|
|
2581
|
+
return undefined;
|
|
2582
|
+
// A request body is a one-shot stream. Only methods whose request semantics are replayable are
|
|
2583
|
+
// eligible for 404 fallthrough; this intentionally refuses POST/PUT/PATCH even when the body is
|
|
2584
|
+
// empty because an adapter may expose a delayed stream or a protocol-specific side effect.
|
|
2585
|
+
const replayable = source.method === "GET" || source.method === "HEAD" || source.method === "OPTIONS";
|
|
2586
|
+
const dispatch = (index, fallback) => {
|
|
2587
|
+
let mount;
|
|
2588
|
+
for (let i = index; i < this.fetchMounts.length; i++) {
|
|
2589
|
+
const candidate = this.fetchMounts[i];
|
|
2590
|
+
if (candidate.beforeRoutes === beforeRoutes && underMountPrefix(pathname, candidate.path)) {
|
|
2591
|
+
mount = candidate;
|
|
2592
|
+
index = i;
|
|
2593
|
+
break;
|
|
2594
|
+
}
|
|
2595
|
+
}
|
|
2596
|
+
if (mount === undefined)
|
|
2597
|
+
return fallback ?? jsonError(404, "not_found");
|
|
2598
|
+
const childRequest = mount.stripPrefix ? stripMountPrefix(request, mount.path) : request;
|
|
2599
|
+
const outcome = mount.handler(childRequest, platform);
|
|
2600
|
+
if (!replayable || !mount.fallbackOn404)
|
|
2601
|
+
return outcome;
|
|
2602
|
+
if (outcome instanceof Promise) {
|
|
2603
|
+
return outcome.then((response) => response.status === 404 ? dispatch(index + 1, response) : response);
|
|
2604
|
+
}
|
|
2605
|
+
return outcome.status === 404 ? dispatch(index + 1, outcome) : outcome;
|
|
2606
|
+
};
|
|
2607
|
+
return dispatch(first);
|
|
1999
2608
|
}
|
|
2000
2609
|
/** Run a route that has already been matched by the runtime or Nifra's portable router. */
|
|
2001
2610
|
runMatched(source, platform, entry, params, search, finalize, wrapResponse, onTimeout, webFast) {
|
|
@@ -2008,8 +2617,10 @@ export class Server {
|
|
|
2008
2617
|
// handler can reach `c.req`; that lane already enforces `entry.bodyLimit`. Installing the full
|
|
2009
2618
|
// direct-reader cap on `c.req` here would allocate bound readers, closures, and a stream wrapper
|
|
2010
2619
|
// for a body that is already consumed. Keep the lazy transport cap for raw-body routes, where a
|
|
2011
|
-
// user read is the only framework-owned body boundary
|
|
2012
|
-
|
|
2620
|
+
// user read is the only framework-owned body boundary, and for the auth-first lane, whose
|
|
2621
|
+
// derive and beforeHandle hooks reach `c.req` before the body is read.
|
|
2622
|
+
if (entry.bodyLimit !== undefined &&
|
|
2623
|
+
(entry.schema?.body === undefined || entry.program.authBeforeValidation)) {
|
|
2013
2624
|
const method = source.method;
|
|
2014
2625
|
if (method !== "GET" && method !== "HEAD")
|
|
2015
2626
|
markTransportCap(source, entry.bodyLimit);
|
|
@@ -2256,7 +2867,7 @@ export class Server {
|
|
|
2256
2867
|
? RequestContext.native(source, params, search, maxBodyBytes, platform, this.protoPoisoning)
|
|
2257
2868
|
: new RequestContext(source, params, search, signal, budget, platform, maxBodyBytes, this.protoPoisoning);
|
|
2258
2869
|
const finish = (value) => runParsed(value, ctx, finalize, wrapResponse);
|
|
2259
|
-
return readBodyFramed(source, maxBodyBytes, this.protoPoisoning, finish, wrapResponse, (err) => logError(err, ctx, finalize, wrapResponse));
|
|
2870
|
+
return readBodyFramed(source, maxBodyBytes, this.protoPoisoning, finish, wrapResponse, (err) => logError(err, ctx, finalize, wrapResponse), bodySchema);
|
|
2260
2871
|
};
|
|
2261
2872
|
}
|
|
2262
2873
|
/** Web adapter wrapper for the shared body runner. Node passes its native finalizer directly through
|
|
@@ -2628,14 +3239,294 @@ export class Server {
|
|
|
2628
3239
|
ctx.body = validation.value;
|
|
2629
3240
|
return runHandlerValidated();
|
|
2630
3241
|
};
|
|
2631
|
-
return readBodyFramed(source, maxBodyBytes, this.protoPoisoning, onParsed, (response) => this.wrapWebResponse(response), (err) => Promise.resolve(logError(err, ctx)));
|
|
3242
|
+
return readBodyFramed(source, maxBodyBytes, this.protoPoisoning, onParsed, (response) => this.wrapWebResponse(response), (err) => Promise.resolve(logError(err, ctx)), bodySchema);
|
|
3243
|
+
};
|
|
3244
|
+
}
|
|
3245
|
+
/**
|
|
3246
|
+
* Generic (finalize/wrapResponse-parameterized) fused runner for the derive-before(-after) lane:
|
|
3247
|
+
* the Node-direct counterpart of `buildFusedDeriveBefore` / `buildFusedDeriveBeforeAfter`.
|
|
3248
|
+
*
|
|
3249
|
+
* The Web fused builders above return a `Response`, so the Node dispatcher (`webFast === false`)
|
|
3250
|
+
* cannot use them and a derive+before route falls back to the generic route program there - two
|
|
3251
|
+
* staged loops, per-stage dispatch, and the async-continuation machinery - while Bun rides the
|
|
3252
|
+
* single closure. That asymmetry is the realistic-route Node deficit against Fastify: this runner
|
|
3253
|
+
* closes it by running validate → derive → before → handler → (after) in one frame and finalizing
|
|
3254
|
+
* through the caller's own `finalize` (the Node outcome on the direct lane, `toResponse` on Web),
|
|
3255
|
+
* so the same registration feeds both runtimes with no per-lane copy of the lifecycle to drift.
|
|
3256
|
+
*
|
|
3257
|
+
* Semantics mirror the generic program stage-for-stage for the shapes this lane admits (exactly
|
|
3258
|
+
* one derive, one beforeHandle, zero-or-one afterHandle, no onError/around/decorations/contracts/
|
|
3259
|
+
* recovery hooks - enforced by `selectRouteLanes`): thrown `Response`/`status(...)` are control
|
|
3260
|
+
* flow through `wrapResponse`/`finalize`, anything else logs + 500s via the shared `bareError`
|
|
3261
|
+
* lane (identical to `handleLifecycleError` with no `onError` hooks), and a terminal `finalize`
|
|
3262
|
+
* throw (e.g. an unstringifiable handler return on the Node lane) is caught exactly as
|
|
3263
|
+
* `finishLifecycleContract` catches it. Step functions close over registration state only and take
|
|
3264
|
+
* the request state as arguments, so the hot path allocates no per-request closures.
|
|
3265
|
+
*/
|
|
3266
|
+
buildFusedDeriveBeforeRunner(handler, derive, before, after, querySchema, maxBodyBytes) {
|
|
3267
|
+
const logError = (err, ctx, finalize, wrapResponse) => this.bareError(err, ctx, finalize, wrapResponse);
|
|
3268
|
+
const runFinish = (result, ctx, finalize, wrapResponse) => {
|
|
3269
|
+
if (after === undefined) {
|
|
3270
|
+
try {
|
|
3271
|
+
return finalize(result, responseSet(ctx), ctx);
|
|
3272
|
+
}
|
|
3273
|
+
catch (err) {
|
|
3274
|
+
return logError(err, ctx, finalize, wrapResponse);
|
|
3275
|
+
}
|
|
3276
|
+
}
|
|
3277
|
+
let transformed;
|
|
3278
|
+
try {
|
|
3279
|
+
transformed = after(result, ctx);
|
|
3280
|
+
}
|
|
3281
|
+
catch (err) {
|
|
3282
|
+
return logError(err, ctx, finalize, wrapResponse);
|
|
3283
|
+
}
|
|
3284
|
+
if (transformed instanceof Promise) {
|
|
3285
|
+
return transformed.then((value) => {
|
|
3286
|
+
try {
|
|
3287
|
+
return finalize(value, responseSet(ctx), ctx);
|
|
3288
|
+
}
|
|
3289
|
+
catch (err) {
|
|
3290
|
+
return logError(err, ctx, finalize, wrapResponse);
|
|
3291
|
+
}
|
|
3292
|
+
}, (err) => logError(err, ctx, finalize, wrapResponse));
|
|
3293
|
+
}
|
|
3294
|
+
try {
|
|
3295
|
+
return finalize(transformed, responseSet(ctx), ctx);
|
|
3296
|
+
}
|
|
3297
|
+
catch (err) {
|
|
3298
|
+
return logError(err, ctx, finalize, wrapResponse);
|
|
3299
|
+
}
|
|
3300
|
+
};
|
|
3301
|
+
const finalizeEarly = (result, ctx, finalize, wrapResponse) => {
|
|
3302
|
+
try {
|
|
3303
|
+
return finalize(result, responseSet(ctx), ctx);
|
|
3304
|
+
}
|
|
3305
|
+
catch (err) {
|
|
3306
|
+
return logError(err, ctx, finalize, wrapResponse);
|
|
3307
|
+
}
|
|
3308
|
+
};
|
|
3309
|
+
const runHandler = (ctx, finalize, wrapResponse) => {
|
|
3310
|
+
let result;
|
|
3311
|
+
try {
|
|
3312
|
+
result = handler(ctx);
|
|
3313
|
+
}
|
|
3314
|
+
catch (err) {
|
|
3315
|
+
return logError(err, ctx, finalize, wrapResponse);
|
|
3316
|
+
}
|
|
3317
|
+
if (result instanceof Promise) {
|
|
3318
|
+
return result.then((value) => runFinish(value, ctx, finalize, wrapResponse), (err) => logError(err, ctx, finalize, wrapResponse));
|
|
3319
|
+
}
|
|
3320
|
+
return runFinish(result, ctx, finalize, wrapResponse);
|
|
3321
|
+
};
|
|
3322
|
+
const runBefore = (ctx, finalize, wrapResponse) => {
|
|
3323
|
+
let early;
|
|
3324
|
+
try {
|
|
3325
|
+
early = before(ctx);
|
|
3326
|
+
}
|
|
3327
|
+
catch (err) {
|
|
3328
|
+
return logError(err, ctx, finalize, wrapResponse);
|
|
3329
|
+
}
|
|
3330
|
+
if (early instanceof Promise) {
|
|
3331
|
+
return early.then((settled) => settled === undefined
|
|
3332
|
+
? runHandler(ctx, finalize, wrapResponse)
|
|
3333
|
+
: finalizeEarly(settled, ctx, finalize, wrapResponse), (err) => logError(err, ctx, finalize, wrapResponse));
|
|
3334
|
+
}
|
|
3335
|
+
if (early === undefined)
|
|
3336
|
+
return runHandler(ctx, finalize, wrapResponse);
|
|
3337
|
+
return finalizeEarly(early, ctx, finalize, wrapResponse);
|
|
3338
|
+
};
|
|
3339
|
+
const runDerive = (ctx, finalize, wrapResponse) => {
|
|
3340
|
+
let derived;
|
|
3341
|
+
try {
|
|
3342
|
+
derived = derive(ctx);
|
|
3343
|
+
}
|
|
3344
|
+
catch (err) {
|
|
3345
|
+
return logError(err, ctx, finalize, wrapResponse);
|
|
3346
|
+
}
|
|
3347
|
+
if (derived instanceof Promise) {
|
|
3348
|
+
return derived.then((settled) => {
|
|
3349
|
+
if (isResponseResult(settled) || settled instanceof Response) {
|
|
3350
|
+
return finalizeEarly(settled, ctx, finalize, wrapResponse);
|
|
3351
|
+
}
|
|
3352
|
+
Object.assign(ctx, settled);
|
|
3353
|
+
return runBefore(ctx, finalize, wrapResponse);
|
|
3354
|
+
}, (err) => logError(err, ctx, finalize, wrapResponse));
|
|
3355
|
+
}
|
|
3356
|
+
if (isResponseResult(derived) || derived instanceof Response) {
|
|
3357
|
+
return finalizeEarly(derived, ctx, finalize, wrapResponse);
|
|
3358
|
+
}
|
|
3359
|
+
Object.assign(ctx, derived);
|
|
3360
|
+
return runBefore(ctx, finalize, wrapResponse);
|
|
3361
|
+
};
|
|
3362
|
+
const runValidated = (validation, ctx, finalize, wrapResponse) => {
|
|
3363
|
+
if (validation instanceof Promise) {
|
|
3364
|
+
return validation.then((settled) => {
|
|
3365
|
+
if (settled.issues !== undefined)
|
|
3366
|
+
return wrapResponse(plainValidationError(settled.issues));
|
|
3367
|
+
ctx.query = settled.value;
|
|
3368
|
+
return runDerive(ctx, finalize, wrapResponse);
|
|
3369
|
+
}, (err) => logError(err, ctx, finalize, wrapResponse));
|
|
3370
|
+
}
|
|
3371
|
+
if (validation.issues !== undefined)
|
|
3372
|
+
return wrapResponse(plainValidationError(validation.issues));
|
|
3373
|
+
ctx.query = validation.value;
|
|
3374
|
+
return runDerive(ctx, finalize, wrapResponse);
|
|
3375
|
+
};
|
|
3376
|
+
return (source, params, search, signal, budget, platform, nativeContext, finalize, wrapResponse) => {
|
|
3377
|
+
const ctx = nativeContext
|
|
3378
|
+
? RequestContext.native(source, params, search, maxBodyBytes, platform, this.protoPoisoning)
|
|
3379
|
+
: new RequestContext(source, params, search, signal, budget, platform, maxBodyBytes, this.protoPoisoning);
|
|
3380
|
+
if (querySchema === undefined)
|
|
3381
|
+
return runDerive(ctx, finalize, wrapResponse);
|
|
3382
|
+
let validation;
|
|
3383
|
+
try {
|
|
3384
|
+
validation = querySchema["~standard"].validate(queryObjectOf(ctx[CONTEXT_SEARCH]));
|
|
3385
|
+
}
|
|
3386
|
+
catch (err) {
|
|
3387
|
+
return logError(err, ctx, finalize, wrapResponse);
|
|
3388
|
+
}
|
|
3389
|
+
return runValidated(validation, ctx, finalize, wrapResponse);
|
|
3390
|
+
};
|
|
3391
|
+
}
|
|
3392
|
+
/**
|
|
3393
|
+
* Generic fused runner for the body+derive-before(-after) lane: the Node-direct counterpart of
|
|
3394
|
+
* `buildFusedBodyDeriveBeforeAfter`. Same contract as `buildFusedDeriveBeforeRunner` above - one
|
|
3395
|
+
* frame running parse → validate → derive → before → handler → (after) through the caller's own
|
|
3396
|
+
* `finalize` - with the body read ahead of validation, exactly as the generic program orders its
|
|
3397
|
+
* body stage before the derive/before stages. `readBodyFramed` keeps the shared bounded-read,
|
|
3398
|
+
* content-type, and prototype-poisoning contracts; the 415/400 renders take `wrapResponse`
|
|
3399
|
+
* directly, which is what the generic lane's wrap-after-identity amounts to.
|
|
3400
|
+
*/
|
|
3401
|
+
buildFusedBodyDeriveBeforeAfterRunner(handler, derive, before, after, bodySchema, maxBodyBytes) {
|
|
3402
|
+
const logError = (err, ctx, finalize, wrapResponse) => this.bareError(err, ctx, finalize, wrapResponse);
|
|
3403
|
+
const runFinish = (result, ctx, finalize, wrapResponse) => {
|
|
3404
|
+
if (after === undefined) {
|
|
3405
|
+
try {
|
|
3406
|
+
return finalize(result, responseSet(ctx), ctx);
|
|
3407
|
+
}
|
|
3408
|
+
catch (err) {
|
|
3409
|
+
return logError(err, ctx, finalize, wrapResponse);
|
|
3410
|
+
}
|
|
3411
|
+
}
|
|
3412
|
+
let transformed;
|
|
3413
|
+
try {
|
|
3414
|
+
transformed = after(result, ctx);
|
|
3415
|
+
}
|
|
3416
|
+
catch (err) {
|
|
3417
|
+
return logError(err, ctx, finalize, wrapResponse);
|
|
3418
|
+
}
|
|
3419
|
+
if (transformed instanceof Promise) {
|
|
3420
|
+
return transformed.then((value) => {
|
|
3421
|
+
try {
|
|
3422
|
+
return finalize(value, responseSet(ctx), ctx);
|
|
3423
|
+
}
|
|
3424
|
+
catch (err) {
|
|
3425
|
+
return logError(err, ctx, finalize, wrapResponse);
|
|
3426
|
+
}
|
|
3427
|
+
}, (err) => logError(err, ctx, finalize, wrapResponse));
|
|
3428
|
+
}
|
|
3429
|
+
try {
|
|
3430
|
+
return finalize(transformed, responseSet(ctx), ctx);
|
|
3431
|
+
}
|
|
3432
|
+
catch (err) {
|
|
3433
|
+
return logError(err, ctx, finalize, wrapResponse);
|
|
3434
|
+
}
|
|
3435
|
+
};
|
|
3436
|
+
const finalizeEarly = (result, ctx, finalize, wrapResponse) => {
|
|
3437
|
+
try {
|
|
3438
|
+
return finalize(result, responseSet(ctx), ctx);
|
|
3439
|
+
}
|
|
3440
|
+
catch (err) {
|
|
3441
|
+
return logError(err, ctx, finalize, wrapResponse);
|
|
3442
|
+
}
|
|
3443
|
+
};
|
|
3444
|
+
const runHandler = (ctx, finalize, wrapResponse) => {
|
|
3445
|
+
let result;
|
|
3446
|
+
try {
|
|
3447
|
+
result = handler(ctx);
|
|
3448
|
+
}
|
|
3449
|
+
catch (err) {
|
|
3450
|
+
return logError(err, ctx, finalize, wrapResponse);
|
|
3451
|
+
}
|
|
3452
|
+
if (result instanceof Promise) {
|
|
3453
|
+
return result.then((value) => runFinish(value, ctx, finalize, wrapResponse), (err) => logError(err, ctx, finalize, wrapResponse));
|
|
3454
|
+
}
|
|
3455
|
+
return runFinish(result, ctx, finalize, wrapResponse);
|
|
3456
|
+
};
|
|
3457
|
+
const runBefore = (ctx, finalize, wrapResponse) => {
|
|
3458
|
+
let early;
|
|
3459
|
+
try {
|
|
3460
|
+
early = before(ctx);
|
|
3461
|
+
}
|
|
3462
|
+
catch (err) {
|
|
3463
|
+
return logError(err, ctx, finalize, wrapResponse);
|
|
3464
|
+
}
|
|
3465
|
+
if (early instanceof Promise) {
|
|
3466
|
+
return early.then((settled) => settled === undefined
|
|
3467
|
+
? runHandler(ctx, finalize, wrapResponse)
|
|
3468
|
+
: finalizeEarly(settled, ctx, finalize, wrapResponse), (err) => logError(err, ctx, finalize, wrapResponse));
|
|
3469
|
+
}
|
|
3470
|
+
if (early === undefined)
|
|
3471
|
+
return runHandler(ctx, finalize, wrapResponse);
|
|
3472
|
+
return finalizeEarly(early, ctx, finalize, wrapResponse);
|
|
3473
|
+
};
|
|
3474
|
+
const runDerive = (ctx, finalize, wrapResponse) => {
|
|
3475
|
+
let derived;
|
|
3476
|
+
try {
|
|
3477
|
+
derived = derive(ctx);
|
|
3478
|
+
}
|
|
3479
|
+
catch (err) {
|
|
3480
|
+
return logError(err, ctx, finalize, wrapResponse);
|
|
3481
|
+
}
|
|
3482
|
+
if (derived instanceof Promise) {
|
|
3483
|
+
return derived.then((settled) => {
|
|
3484
|
+
if (isResponseResult(settled) || settled instanceof Response) {
|
|
3485
|
+
return finalizeEarly(settled, ctx, finalize, wrapResponse);
|
|
3486
|
+
}
|
|
3487
|
+
Object.assign(ctx, settled);
|
|
3488
|
+
return runBefore(ctx, finalize, wrapResponse);
|
|
3489
|
+
}, (err) => logError(err, ctx, finalize, wrapResponse));
|
|
3490
|
+
}
|
|
3491
|
+
if (isResponseResult(derived) || derived instanceof Response) {
|
|
3492
|
+
return finalizeEarly(derived, ctx, finalize, wrapResponse);
|
|
3493
|
+
}
|
|
3494
|
+
Object.assign(ctx, derived);
|
|
3495
|
+
return runBefore(ctx, finalize, wrapResponse);
|
|
3496
|
+
};
|
|
3497
|
+
const onParsed = (parsed, ctx, finalize, wrapResponse) => {
|
|
3498
|
+
let validation;
|
|
3499
|
+
try {
|
|
3500
|
+
validation = bodySchema["~standard"].validate(parsed);
|
|
3501
|
+
}
|
|
3502
|
+
catch (err) {
|
|
3503
|
+
return logError(err, ctx, finalize, wrapResponse);
|
|
3504
|
+
}
|
|
3505
|
+
if (validation instanceof Promise) {
|
|
3506
|
+
return validation.then((settled) => {
|
|
3507
|
+
if (settled.issues !== undefined)
|
|
3508
|
+
return wrapResponse(plainValidationError(settled.issues));
|
|
3509
|
+
ctx.body = settled.value;
|
|
3510
|
+
return runDerive(ctx, finalize, wrapResponse);
|
|
3511
|
+
}, (err) => logError(err, ctx, finalize, wrapResponse));
|
|
3512
|
+
}
|
|
3513
|
+
if (validation.issues !== undefined)
|
|
3514
|
+
return wrapResponse(plainValidationError(validation.issues));
|
|
3515
|
+
ctx.body = validation.value;
|
|
3516
|
+
return runDerive(ctx, finalize, wrapResponse);
|
|
3517
|
+
};
|
|
3518
|
+
return (source, params, search, signal, budget, platform, nativeContext, finalize, wrapResponse) => {
|
|
3519
|
+
const ctx = nativeContext
|
|
3520
|
+
? RequestContext.native(source, params, search, maxBodyBytes, platform, this.protoPoisoning)
|
|
3521
|
+
: new RequestContext(source, params, search, signal, budget, platform, maxBodyBytes, this.protoPoisoning);
|
|
3522
|
+
return readBodyFramed(source, maxBodyBytes, this.protoPoisoning, (parsed) => onParsed(parsed, ctx, finalize, wrapResponse), wrapResponse, (err) => logError(err, ctx, finalize, wrapResponse), bodySchema);
|
|
2632
3523
|
};
|
|
2633
3524
|
}
|
|
2634
3525
|
bareError(err, ctx, finalize, wrapResponse) {
|
|
2635
3526
|
// The thrown-value contract lives in `bare-error-lane.ts`, one source of truth for both this
|
|
2636
3527
|
// lane and the fused body runner. `responseSet` and the logger are injected because they reach
|
|
2637
3528
|
// into class state; the closure is allocated only on the throw path, never on the hot lane.
|
|
2638
|
-
return renderBareError(err, ctx, finalize,
|
|
3529
|
+
return renderBareError(err, ctx, finalize, responseSet, (e, c) => this.logRequestError(e, c), () => this.internalErrorResponse(wrapResponse));
|
|
2639
3530
|
}
|
|
2640
3531
|
/**
|
|
2641
3532
|
* Keep the ordinary thrown-error response independent from the caught value. Deliberate Response
|
|
@@ -2665,15 +3556,13 @@ export class Server {
|
|
|
2665
3556
|
/** Bind a compiled validation stage to its stable schema and the existing recovery contract. */
|
|
2666
3557
|
// @ts-expect-error TS6133 -- invoked structurally by the general route program
|
|
2667
3558
|
validateProgramStage(entry, stage, source, ctx) {
|
|
2668
|
-
const input = stage.kind === "headers"
|
|
2669
|
-
? headerObjectOf(source.headers)
|
|
2670
|
-
: stage.kind === "params"
|
|
2671
|
-
? ctx.params
|
|
2672
|
-
: stage.kind === "query"
|
|
2673
|
-
? queryObjectOf(ctx[CONTEXT_SEARCH])
|
|
2674
|
-
: undefined;
|
|
2675
3559
|
if (stage.kind === "body")
|
|
2676
3560
|
return this.readProgramBody(entry, source, ctx);
|
|
3561
|
+
const input = stage.kind === "headers"
|
|
3562
|
+
? headerObjectOf(source.headers)
|
|
3563
|
+
: stage.kind === "query"
|
|
3564
|
+
? queryObjectOf(ctx[CONTEXT_SEARCH])
|
|
3565
|
+
: ctx[stage.kind];
|
|
2677
3566
|
const validation = stage.schema["~standard"].validate(input);
|
|
2678
3567
|
return validation instanceof Promise
|
|
2679
3568
|
? validation.then((result) => this.applyLifecycleValidation(entry, result, ctx, stage.kind))
|
|
@@ -2713,7 +3602,7 @@ export class Server {
|
|
|
2713
3602
|
}
|
|
2714
3603
|
// @ts-expect-error TS6133 -- invoked structurally by compiled plans (internal/route-execution.ts)
|
|
2715
3604
|
runBodyOnly(entry, source, ctx, finalize, wrapResponse) {
|
|
2716
|
-
return readBodyFramed(source, entry.bodyLimit ?? UNLIMITED_BODY_BYTES, this.protoPoisoning, (parsed) => this.finishBodyOnly(entry, parsed, ctx, finalize, wrapResponse), wrapResponse, (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse));
|
|
3605
|
+
return readBodyFramed(source, entry.bodyLimit ?? UNLIMITED_BODY_BYTES, this.protoPoisoning, (parsed) => this.finishBodyOnly(entry, parsed, ctx, finalize, wrapResponse), wrapResponse, (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse), entry.schema?.body);
|
|
2717
3606
|
}
|
|
2718
3607
|
/** Validate + run the handler for the bodyOnly path - shared by the inline fast path and the
|
|
2719
3608
|
* streaming fallback. A method (not per-request closures) so the hot path allocates nothing
|
|
@@ -2961,7 +3850,7 @@ export class Server {
|
|
|
2961
3850
|
const contract = entry.responseContract;
|
|
2962
3851
|
if (contract === undefined)
|
|
2963
3852
|
return finalize(result, responseSet(ctx));
|
|
2964
|
-
const checked = contract.runtime.check(contract.
|
|
3853
|
+
const checked = contract.runtime.check(contract.definition, result, ctx);
|
|
2965
3854
|
if (checked instanceof Promise) {
|
|
2966
3855
|
return checked.then((outcome) => this.finishContractOutcome(ctx, finalize, wrapResponse, outcome), (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse));
|
|
2967
3856
|
}
|
|
@@ -2996,15 +3885,16 @@ export class Server {
|
|
|
2996
3885
|
* loop, which may await, drops into {@link runErrorHooks}. */
|
|
2997
3886
|
handleLifecycleError(entry, err, ctx, finalize, wrapResponse) {
|
|
2998
3887
|
// A *thrown* Response is deliberate control flow, not an error - a guard throws a redirect/401,
|
|
2999
|
-
// an action throws an error page.
|
|
3000
|
-
//
|
|
3001
|
-
//
|
|
3888
|
+
// an action throws an error page. Send it as the same Response returned would be sent (Remix/
|
|
3889
|
+
// SvelteKit semantics): as built, plus the cookies the request queued, so a session set or
|
|
3890
|
+
// cleared before `throw redirect(...)` / `requireSession(...)` ships. Don't run onError or log
|
|
3891
|
+
// it as a 500.
|
|
3002
3892
|
if (err instanceof Response)
|
|
3003
|
-
return
|
|
3893
|
+
return finalize(err, responseSet(ctx));
|
|
3004
3894
|
// Same rule for a thrown `status(...)`, but through `finalize` rather than `wrapResponse`: the
|
|
3005
3895
|
// value is still plain data, so the ordinary JSON lane renders it and no `Response` is built.
|
|
3006
3896
|
if (isResponseResult(err))
|
|
3007
|
-
return
|
|
3897
|
+
return this.finishLifecycleContract(entry, ctx, finalize, wrapResponse, err);
|
|
3008
3898
|
if (entry.onError.length === 0) {
|
|
3009
3899
|
// Never crash the server or leak internals. The client gets a flat 500; the detail goes to the
|
|
3010
3900
|
// (redacting) logger. Body-read failures and around-hook failures land here too.
|
|
@@ -3043,20 +3933,14 @@ export class Server {
|
|
|
3043
3933
|
? validation.then((result) => this.applyLifecycleValidation(entry, result, ctx, "body"))
|
|
3044
3934
|
: this.applyLifecycleValidation(entry, validation, ctx, "body");
|
|
3045
3935
|
};
|
|
3046
|
-
return readBodyFramed(req, bodyLimit ?? UNLIMITED_BODY_BYTES, this.protoPoisoning, validate, (response) => response, (error) => Promise.reject(error));
|
|
3936
|
+
return readBodyFramed(req, bodyLimit ?? UNLIMITED_BODY_BYTES, this.protoPoisoning, validate, (response) => response, (error) => Promise.reject(error), bodySchema);
|
|
3047
3937
|
}
|
|
3048
3938
|
/** Apply validation and its recovery hook on the generic lifecycle lane. Recovery is completed
|
|
3049
3939
|
* before derives/beforeHandle run, matching the body-only and query-only execution lanes. */
|
|
3050
3940
|
applyLifecycleValidation(entry, result, ctx, kind) {
|
|
3051
3941
|
const assign = (value) => {
|
|
3052
|
-
|
|
3053
|
-
|
|
3054
|
-
else if (kind === "query")
|
|
3055
|
-
ctx.query = value;
|
|
3056
|
-
else if (kind === "headers")
|
|
3057
|
-
ctx.headers = value;
|
|
3058
|
-
else
|
|
3059
|
-
ctx.params = value;
|
|
3942
|
+
;
|
|
3943
|
+
ctx[kind] = value;
|
|
3060
3944
|
};
|
|
3061
3945
|
if (result.issues === undefined) {
|
|
3062
3946
|
assign(result.value);
|
|
@@ -3076,14 +3960,7 @@ export class Server {
|
|
|
3076
3960
|
return plainValidationError(issues);
|
|
3077
3961
|
if (recovery instanceof Response)
|
|
3078
3962
|
return recovery;
|
|
3079
|
-
const
|
|
3080
|
-
? entry.schema?.body
|
|
3081
|
-
: kind === "query"
|
|
3082
|
-
? entry.schema?.query
|
|
3083
|
-
: kind === "params"
|
|
3084
|
-
? entry.schema?.params
|
|
3085
|
-
: entry.schema?.headers;
|
|
3086
|
-
const retried = schema["~standard"].validate(recovery);
|
|
3963
|
+
const retried = entry.schema[kind]["~standard"].validate(recovery);
|
|
3087
3964
|
if (retried instanceof Promise) {
|
|
3088
3965
|
return retried.then((settled) => {
|
|
3089
3966
|
if (settled.issues !== undefined)
|
|
@@ -3099,7 +3976,7 @@ export class Server {
|
|
|
3099
3976
|
}
|
|
3100
3977
|
/** Adapt one route's compiled execution plan to Bun's already-matched request shape. Route
|
|
3101
3978
|
* semantics remain in the plan; this closure only supplies native params and deadline fallback. */
|
|
3102
|
-
compileBunNativeHandler(entry, paramNames, fused, signal, budget) {
|
|
3979
|
+
compileBunNativeHandler(entry, paramNames, fused, signal, budget, peer, fallback) {
|
|
3103
3980
|
// This callback is reached only from Bun's compiled native route table. Bun has already parsed
|
|
3104
3981
|
// the HTTP framing, so the JSON lane may retain its native fused `json()` parse without weakening
|
|
3105
3982
|
// the portable/adapted source contract (those sources are never marked).
|
|
@@ -3107,7 +3984,9 @@ export class Server {
|
|
|
3107
3984
|
// The fused native lane bypasses `runMatched`, so the route's transport byte cap must be
|
|
3108
3985
|
// marked here too - otherwise a Bun `listen()` fused route would leave direct `c.req` body
|
|
3109
3986
|
// reads uncapped. The non-fused branches go through `fetchMatched` -> `runMatched`, which marks.
|
|
3110
|
-
const bodyLimit = entry.schema?.body === undefined
|
|
3987
|
+
const bodyLimit = entry.schema?.body === undefined || entry.program.authBeforeValidation
|
|
3988
|
+
? entry.bodyLimit
|
|
3989
|
+
: undefined;
|
|
3111
3990
|
const inner = fused;
|
|
3112
3991
|
const capped = inner === undefined || bodyLimit === undefined
|
|
3113
3992
|
? inner
|
|
@@ -3117,73 +3996,90 @@ export class Server {
|
|
|
3117
3996
|
markTransportCap(source, bodyLimit);
|
|
3118
3997
|
return inner(source, params, search, fusedSignal, fusedBudget, platform, nativeContext);
|
|
3119
3998
|
};
|
|
3999
|
+
const nativeResponseHooks = this.onResponseHooks.length > 0 && this.bunNativeResponseHeadersOnly;
|
|
4000
|
+
const finishNative = (request, response) => {
|
|
4001
|
+
if (!nativeResponseHooks)
|
|
4002
|
+
return response;
|
|
4003
|
+
return this.applyOnResponseAndFinalize(response, request);
|
|
4004
|
+
};
|
|
4005
|
+
const runNative = (request, params) => {
|
|
4006
|
+
const outcome = capped(request, params, undefined, signal, budget, peer, true);
|
|
4007
|
+
return outcome instanceof Promise
|
|
4008
|
+
? outcome.then((response) => finishNative(request, response))
|
|
4009
|
+
: finishNative(request, outcome);
|
|
4010
|
+
};
|
|
3120
4011
|
if (paramNames.length === 0) {
|
|
3121
4012
|
if (capped === undefined) {
|
|
3122
4013
|
return (request) => {
|
|
3123
4014
|
markFramed(request);
|
|
3124
|
-
return this.fetchMatched(request, entry, EMPTY_PARAMS);
|
|
4015
|
+
return this.fetchMatched(request, entry, EMPTY_PARAMS, peer);
|
|
3125
4016
|
};
|
|
3126
4017
|
}
|
|
3127
4018
|
if (this.acceptInboundDeadlines) {
|
|
3128
4019
|
return (request) => {
|
|
3129
4020
|
markFramed(request);
|
|
3130
4021
|
return request.headers.get(NIFRA_DEADLINE_HEADER) !== null
|
|
3131
|
-
? this.fetchMatched(request, entry, EMPTY_PARAMS)
|
|
3132
|
-
: capped(request, EMPTY_PARAMS, undefined, signal, budget,
|
|
4022
|
+
? this.fetchMatched(request, entry, EMPTY_PARAMS, peer)
|
|
4023
|
+
: capped(request, EMPTY_PARAMS, undefined, signal, budget, peer, true);
|
|
3133
4024
|
};
|
|
3134
4025
|
}
|
|
3135
4026
|
return (request) => {
|
|
3136
4027
|
markFramed(request);
|
|
3137
|
-
return
|
|
4028
|
+
return runNative(request, EMPTY_PARAMS);
|
|
3138
4029
|
};
|
|
3139
4030
|
}
|
|
3140
4031
|
const malformed = paramNames.length === 1
|
|
3141
|
-
? (params) => params[paramNames[0]]
|
|
3142
|
-
:
|
|
4032
|
+
? (params) => unroutedParam(params[paramNames[0]])
|
|
4033
|
+
: hasUnroutedParam;
|
|
3143
4034
|
if (capped === undefined) {
|
|
3144
|
-
return (request) => {
|
|
4035
|
+
return (request, server) => {
|
|
3145
4036
|
markFramed(request);
|
|
3146
4037
|
const params = request.params ?? EMPTY_PARAMS;
|
|
3147
4038
|
if (malformed(params))
|
|
3148
|
-
return
|
|
3149
|
-
return this.fetchMatched(request, entry, params);
|
|
4039
|
+
return fallback(request, server);
|
|
4040
|
+
return this.fetchMatched(request, entry, params, peer);
|
|
3150
4041
|
};
|
|
3151
4042
|
}
|
|
3152
4043
|
if (this.acceptInboundDeadlines) {
|
|
3153
|
-
return (request) => {
|
|
4044
|
+
return (request, server) => {
|
|
3154
4045
|
markFramed(request);
|
|
3155
4046
|
const params = request.params ?? EMPTY_PARAMS;
|
|
3156
4047
|
if (malformed(params))
|
|
3157
|
-
return
|
|
4048
|
+
return fallback(request, server);
|
|
3158
4049
|
return request.headers.get(NIFRA_DEADLINE_HEADER) !== null
|
|
3159
|
-
? this.fetchMatched(request, entry, params)
|
|
3160
|
-
: capped(request, params, undefined, signal, budget,
|
|
4050
|
+
? this.fetchMatched(request, entry, params, peer)
|
|
4051
|
+
: capped(request, params, undefined, signal, budget, peer, true);
|
|
3161
4052
|
};
|
|
3162
4053
|
}
|
|
3163
|
-
return (request) => {
|
|
4054
|
+
return (request, server) => {
|
|
3164
4055
|
markFramed(request);
|
|
3165
4056
|
const params = request.params ?? EMPTY_PARAMS;
|
|
3166
4057
|
if (malformed(params))
|
|
3167
|
-
return
|
|
3168
|
-
return
|
|
4058
|
+
return fallback(request, server);
|
|
4059
|
+
return runNative(request, params);
|
|
3169
4060
|
};
|
|
3170
4061
|
}
|
|
3171
4062
|
/** Compile portable route registrations into Bun's native route table. Apps with request-rewrite
|
|
3172
4063
|
* hooks or WebSockets retain the single portable dispatcher because those features must run before
|
|
3173
|
-
* route selection/upgrade.
|
|
3174
|
-
*
|
|
3175
|
-
|
|
4064
|
+
* route selection/upgrade.
|
|
4065
|
+
*
|
|
4066
|
+
* Bun and the portable router agree on which of two static-and-`:param` paths wins a request, so
|
|
4067
|
+
* the table is kept to those paths. Where Bun would otherwise pass a request on to a less specific
|
|
4068
|
+
* path, the more specific one carries `fallback`, the portable dispatcher, under that method.
|
|
4069
|
+
* `peer` is the platform every request served from the table shares. */
|
|
4070
|
+
buildBunNativeRoutes(fallback, peer) {
|
|
3176
4071
|
// A `clientIp` trust declaration must run the resolver in `dispatch`, which the fused native lane
|
|
3177
4072
|
// bypasses - so an app that declares trust routes through the fetch lane (where `c.clientIp`
|
|
3178
4073
|
// resolves) instead of Bun's native table. The allocation-free default keeps native fusion.
|
|
3179
|
-
if (this.onRequestHooks.length > 0 ||
|
|
4074
|
+
if ((this.onRequestHooks.length > 0 && !this.bunNativeRequestHooksSafe) ||
|
|
3180
4075
|
this.wsRouteCount > 0 ||
|
|
4076
|
+
this.preRouteMountCount > 0 ||
|
|
3181
4077
|
this.clientIpTrust !== undefined) {
|
|
3182
4078
|
return undefined;
|
|
3183
4079
|
}
|
|
3184
4080
|
const routes = Object.create(null);
|
|
3185
4081
|
const mayUseFusedNative = this.requestTimeoutMs === 0 &&
|
|
3186
|
-
this.onResponseHooks.length === 0 &&
|
|
4082
|
+
(this.onResponseHooks.length === 0 || this.bunNativeResponseHeadersOnly) &&
|
|
3187
4083
|
this.onResponseFinalizedHooks.length === 0 &&
|
|
3188
4084
|
// The capacity gate must wrap every request; the fused lane bypasses fetchMatched, so enabling
|
|
3189
4085
|
// admission drops fusion (native matching stays) and routes through the gated matched lane.
|
|
@@ -3191,18 +4087,65 @@ export class Server {
|
|
|
3191
4087
|
const unboundedSignal = mayUseFusedNative ? getNeverAbortSignal() : undefined;
|
|
3192
4088
|
const unboundedBudget = mayUseFusedNative ? getUnboundedRequestBudget() : undefined;
|
|
3193
4089
|
let count = 0;
|
|
3194
|
-
|
|
3195
|
-
|
|
4090
|
+
const all = this.catalog.entries();
|
|
4091
|
+
// A path with a wildcard or a part-literal segment cannot go in the table: Bun reads `/:name.json`
|
|
4092
|
+
// as one parameter named `name.json`. These are filed under each run of static segments they
|
|
4093
|
+
// start with, which is where a table path they could outrank looks for them.
|
|
4094
|
+
const under = new Map();
|
|
4095
|
+
const lead = all.map((route) => {
|
|
4096
|
+
const segments = route.pattern.segments;
|
|
4097
|
+
const plain = segments.every((segment) => segment.kind === "static" || segment.kind === "param");
|
|
4098
|
+
let prefix = "";
|
|
4099
|
+
for (const segment of segments) {
|
|
4100
|
+
if (!plain) {
|
|
4101
|
+
let bucket = under.get(prefix);
|
|
4102
|
+
if (bucket === undefined) {
|
|
4103
|
+
bucket = [];
|
|
4104
|
+
under.set(prefix, bucket);
|
|
4105
|
+
}
|
|
4106
|
+
bucket.push(route);
|
|
4107
|
+
}
|
|
4108
|
+
if (segment.kind !== "static")
|
|
4109
|
+
break;
|
|
4110
|
+
prefix += `/${segment.value}`;
|
|
4111
|
+
}
|
|
4112
|
+
return plain ? prefix : undefined;
|
|
4113
|
+
});
|
|
4114
|
+
// A preflight hook is intentionally handled by the fallback fetch path. Keeping OPTIONS out
|
|
4115
|
+
// of the native lane means Bun dispatches it through CORS's onRequest hook even when the
|
|
4116
|
+
// same path has native GET/POST handlers.
|
|
4117
|
+
const preflightOnFallback = this.onRequestHooks.length > 0 && this.bunNativeRequestHooksSafe;
|
|
4118
|
+
// Where Bun could pass a request down: the methods served natively on a path with a parameter,
|
|
4119
|
+
// as segment count + method. Two table paths match one request only at the same segment count.
|
|
4120
|
+
const below = new Set();
|
|
4121
|
+
const tableAt = (path) => {
|
|
4122
|
+
routes[path] ??= Object.create(null);
|
|
4123
|
+
return routes[path];
|
|
4124
|
+
};
|
|
4125
|
+
for (let i = 0; i < all.length; i++) {
|
|
4126
|
+
const { method, path, pattern, entry } = all[i];
|
|
4127
|
+
const run = lead[i];
|
|
4128
|
+
// The route is served from the table unless its path cannot be there, Bun's table has no slot
|
|
4129
|
+
// for its method, or a path outside the table outranks it: Bun would hand this route a request
|
|
4130
|
+
// the portable router gives to that one. An idempotent route stays off it too: its lanes run on
|
|
4131
|
+
// a buffered copy of the request, and Bun names the peer only of the request it delivered, so
|
|
4132
|
+
// the platform the table shares could not answer `c.clientIp` there.
|
|
4133
|
+
if (run === undefined ||
|
|
4134
|
+
entry.idempotent !== undefined ||
|
|
4135
|
+
!METHODS.includes(method) ||
|
|
4136
|
+
(preflightOnFallback && method === "OPTIONS") ||
|
|
4137
|
+
under.get(run)?.some((other) => outranks(other.pattern.segments, pattern.segments))) {
|
|
3196
4138
|
continue;
|
|
3197
|
-
let methods = routes[path];
|
|
3198
|
-
if (methods === undefined) {
|
|
3199
|
-
methods = Object.create(null);
|
|
3200
|
-
routes[path] = methods;
|
|
3201
4139
|
}
|
|
3202
4140
|
const paramNames = pattern.paramNames;
|
|
3203
4141
|
const fused = mayUseFusedNative ? entry.execution.fusedWeb : undefined;
|
|
3204
|
-
|
|
4142
|
+
tableAt(path)[method] = this.compileBunNativeHandler(entry, paramNames, fused, unboundedSignal, unboundedBudget, peer, fallback);
|
|
3205
4143
|
count += 1;
|
|
4144
|
+
if (paramNames.length > 0) {
|
|
4145
|
+
below.add(pattern.segments.length + method);
|
|
4146
|
+
if (method === "GET")
|
|
4147
|
+
below.add(`${pattern.segments.length}HEAD`);
|
|
4148
|
+
}
|
|
3206
4149
|
}
|
|
3207
4150
|
if (count === 0)
|
|
3208
4151
|
return undefined;
|
|
@@ -3215,6 +4158,19 @@ export class Server {
|
|
|
3215
4158
|
if (methods.GET !== undefined)
|
|
3216
4159
|
methods.HEAD ??= methods.GET;
|
|
3217
4160
|
}
|
|
4161
|
+
// Bun picks among the paths its table holds for the request's method, so a path with no entry
|
|
4162
|
+
// for the method lets the request through to a less specific one. The portable router stops at
|
|
4163
|
+
// the most specific path and answers 405. Every path the table could hold therefore gets the
|
|
4164
|
+
// portable dispatcher under each method a less specific path might serve.
|
|
4165
|
+
for (let i = 0; i < all.length; i++) {
|
|
4166
|
+
if (lead[i] === undefined)
|
|
4167
|
+
continue;
|
|
4168
|
+
const { path, pattern } = all[i];
|
|
4169
|
+
for (const method of METHODS) {
|
|
4170
|
+
if (below.has(pattern.segments.length + method))
|
|
4171
|
+
tableAt(path)[method] ??= fallback;
|
|
4172
|
+
}
|
|
4173
|
+
}
|
|
3218
4174
|
return routes;
|
|
3219
4175
|
}
|
|
3220
4176
|
/**
|
|
@@ -3238,6 +4194,10 @@ export class Server {
|
|
|
3238
4194
|
* connection cut mid-flight regardless of `requestTimeoutMs`. Apps with such endpoints must raise
|
|
3239
4195
|
* this above their slowest expected response, which is why it is a first-class option and not a
|
|
3240
4196
|
* reason to drop down to `Bun.serve`. `0` disables the timeout entirely; max 255.
|
|
4197
|
+
*
|
|
4198
|
+
* `tls` serves HTTPS directly: `app.listen(443, { tls: { cert, key } })` with PEM text or bytes.
|
|
4199
|
+
* Requests then arrive with `https:` URLs. Behind a proxy or a platform that terminates TLS, leave
|
|
4200
|
+
* it unset.
|
|
3241
4201
|
*/
|
|
3242
4202
|
listen(port, options) {
|
|
3243
4203
|
if (typeof Bun === "undefined") {
|
|
@@ -3262,31 +4222,57 @@ export class Server {
|
|
|
3262
4222
|
// because wsRouteCount > 0 means ws() ran, and ws() requires the runtime at registration.
|
|
3263
4223
|
// Native pub/sub when the app has WS routes and none validate outbound frames: `ws.subscribe` and
|
|
3264
4224
|
// `app.publish` go through Bun's own (uWebSockets) broadcast instead of the JS registry loop.
|
|
3265
|
-
|
|
3266
|
-
|
|
3267
|
-
|
|
3268
|
-
|
|
3269
|
-
|
|
3270
|
-
|
|
3271
|
-
//
|
|
3272
|
-
|
|
3273
|
-
//
|
|
3274
|
-
|
|
3275
|
-
const
|
|
4225
|
+
// A mounted app counts only while it has a WebSocket route (see `[GET_WS_RUNTIME]`), so composing
|
|
4226
|
+
// one without any needs no runtime here.
|
|
4227
|
+
const wsRuntime = this[GET_WS_RUNTIME]();
|
|
4228
|
+
if (wsRuntime === null) {
|
|
4229
|
+
throw new FrameworkError("INVALID_WS_RUNTIME", "a Bun server with mounted WebSocket routes needs the websocket() runtime installed on the parent or child app");
|
|
4230
|
+
}
|
|
4231
|
+
// Native pub/sub is safe only when every upgrade belongs to this app. A mounted child that takes
|
|
4232
|
+
// upgrades owns a different TopicRegistry, so force the per-connection JS dispatcher and carry
|
|
4233
|
+
// that registry in the upgrade outcome instead of accidentally broadcasting child sockets through
|
|
4234
|
+
// the parent.
|
|
4235
|
+
const nativePubsub = this.wsRouteCount > 0 && !this.mountsTakeUpgrades() && !this.wsHasValidatedSend;
|
|
4236
|
+
const wsHandlers = wsRuntime?.bunHandlers(this.topics ?? wsRuntime.createTopics(), nativePubsub);
|
|
4237
|
+
// Spread rather than pass `hostname: undefined` (or an undefined idle timeout or TLS) - Bun treats
|
|
4238
|
+
// an explicit undefined as a value on some option paths, and omitting is what selects its own
|
|
4239
|
+
// defaults, 0.0.0.0 among them.
|
|
4240
|
+
const listening = {
|
|
4241
|
+
port,
|
|
4242
|
+
reusePort: options?.reusePort === true,
|
|
4243
|
+
...(options?.hostname === undefined ? {} : { hostname: options.hostname }),
|
|
4244
|
+
...(options?.idleTimeoutSec === undefined ? {} : { idleTimeout: options.idleTimeoutSec }),
|
|
4245
|
+
...(options?.tls === undefined ? {} : { tls: options.tls }),
|
|
4246
|
+
// A hook that throws rejects `fetch`; Bun's own answer to that is a development page carrying
|
|
4247
|
+
// the message, stack and source whenever NODE_ENV is not "production".
|
|
4248
|
+
error: (err) => {
|
|
4249
|
+
emitRequestErrorLog(this.logger, this.errorLogDetail, err, undefined);
|
|
4250
|
+
return jsonError(500, "internal_error");
|
|
4251
|
+
},
|
|
4252
|
+
};
|
|
4253
|
+
const fallback = (req, server) => {
|
|
4254
|
+
// Bun has already framed and bounded this request body in its HTTP parser. Mark the
|
|
4255
|
+
// source before the portable fallback runs so body schemas use Bun's native `json()`
|
|
4256
|
+
// reader instead of the defensive arrayBuffer/decode path reserved for caller-built
|
|
4257
|
+
// Requests. Native route handlers mark themselves in compileBunNativeHandler; this
|
|
4258
|
+
// covers every request the native lane does not serve.
|
|
4259
|
+
markTrustedBodyFraming(req);
|
|
4260
|
+
return this.fetch(req, bunPeerPlatform(server, req));
|
|
4261
|
+
};
|
|
4262
|
+
// Bun names the socket peer of a request when its server is asked. A request served from the
|
|
4263
|
+
// native table carries this one platform, which asks only when a handler reads `c.clientIp`.
|
|
4264
|
+
const peer = {
|
|
4265
|
+
[PLATFORM_PEER]: (request) => running.requestIP(request)?.address,
|
|
4266
|
+
};
|
|
4267
|
+
const nativeRoutes = wsHandlers === undefined ? this.buildBunNativeRoutes(fallback, peer) : undefined;
|
|
3276
4268
|
const running = (wsHandlers === undefined
|
|
3277
4269
|
? Bun.serve({
|
|
3278
|
-
|
|
3279
|
-
reusePort,
|
|
3280
|
-
...bind,
|
|
3281
|
-
...idle,
|
|
4270
|
+
...listening,
|
|
3282
4271
|
...(nativeRoutes === undefined ? {} : { routes: nativeRoutes }),
|
|
3283
|
-
fetch:
|
|
4272
|
+
fetch: fallback,
|
|
3284
4273
|
})
|
|
3285
4274
|
: Bun.serve({
|
|
3286
|
-
|
|
3287
|
-
reusePort,
|
|
3288
|
-
...bind,
|
|
3289
|
-
...idle,
|
|
4275
|
+
...listening,
|
|
3290
4276
|
fetch: (req, server) => this.bunFetchWithWebSocket(req, server),
|
|
3291
4277
|
// Bun's `ServerWebSocket<BunWsData>` is runtime-compatible with the handlers' structural
|
|
3292
4278
|
// `BunSocket` view (kept local so `Bun.*` types never leak into the published .d.ts); the
|
|
@@ -3302,7 +4288,9 @@ export class Server {
|
|
|
3302
4288
|
}));
|
|
3303
4289
|
this.bunServer = running;
|
|
3304
4290
|
// Bind `app.publish` to Bun's native broadcast now that the server handle exists. Guarded on the
|
|
3305
|
-
// method's presence so a runtime whose handle lacks it simply keeps the registry path.
|
|
4291
|
+
// method's presence so a runtime whose handle lacks it simply keeps the registry path. Cleared
|
|
4292
|
+
// first: a mounted app may have gained a WebSocket route since an earlier `listen()`.
|
|
4293
|
+
this.nativePublish = undefined;
|
|
3306
4294
|
if (nativePubsub && typeof running.publish === "function") {
|
|
3307
4295
|
const native = running.publish.bind(running);
|
|
3308
4296
|
this.nativePublish = (topic, data) => {
|