@nifrajs/core 3.3.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 +6 -1
- package/dist/diff.d.ts.map +1 -1
- package/dist/diff.js +36 -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 +36 -1
- package/dist/evidence.d.ts.map +1 -1
- package/dist/evidence.js +219 -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 +48 -9
- package/dist/idempotency.js.map +1 -1
- package/dist/index.d.ts +9 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -3
- 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 +1 -1
- package/dist/manifest.d.ts.map +1 -1
- package/dist/manifest.js +52 -46
- 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/cookies.d.ts +4 -1
- package/dist/server/cookies.d.ts.map +1 -1
- package/dist/server/cookies.js +40 -2
- package/dist/server/cookies.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 +55 -3
- package/dist/server/server-types.d.ts.map +1 -1
- package/dist/server/server.d.ts +194 -19
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +1342 -274
- 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 +8 -5
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +5 -2
- 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 +54 -7
- 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 +302 -12
- package/src/idempotency.ts +80 -9
- package/src/index.ts +41 -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 +52 -46
- 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/cookies.ts +34 -2
- 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 +61 -2
- package/src/server/server.ts +1798 -355
- 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 +39 -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,7 +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" };
|
|
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;
|
|
50
88
|
/** `app.ws()` (and everything downstream of it) needs the runtime `@nifrajs/core/ws` registers. */
|
|
51
89
|
function requireWsRuntime(runtime) {
|
|
52
90
|
if (runtime === undefined) {
|
|
@@ -87,13 +125,27 @@ export { plainValidationError } from "./validation.js";
|
|
|
87
125
|
// Query-string + urlencoded-form parsing now lives in `./query.ts`; re-exported so existing
|
|
88
126
|
// importers keep resolving `searchOf`/`queryObjectOf`/`QueryValue` from here.
|
|
89
127
|
export { queryObjectOf, searchOf };
|
|
90
|
-
|
|
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) {
|
|
91
136
|
for (const key in params) {
|
|
92
|
-
if (params[key]
|
|
137
|
+
if (unroutedParam(params[key]))
|
|
93
138
|
return true;
|
|
94
139
|
}
|
|
95
140
|
return false;
|
|
96
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);
|
|
97
149
|
function normalizeMountPrefix(path) {
|
|
98
150
|
if (!path.startsWith("/") || path.includes("?") || path.includes("#")) {
|
|
99
151
|
throw new TypeError("mountFetch path must be an absolute pathname without a query or hash");
|
|
@@ -111,6 +163,17 @@ function normalizeMountPrefix(path) {
|
|
|
111
163
|
function underMountPrefix(pathname, prefix) {
|
|
112
164
|
return prefix === "/" || pathname === prefix || pathname.startsWith(`${prefix}/`);
|
|
113
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
|
+
}
|
|
114
177
|
function stripMountPrefix(request, prefix) {
|
|
115
178
|
if (prefix === "/")
|
|
116
179
|
return request;
|
|
@@ -139,6 +202,68 @@ function isContextlessNoArgArrow(handler) {
|
|
|
139
202
|
* these renders happen where no request context exists - before routing, or after it was abandoned.
|
|
140
203
|
*/
|
|
141
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
|
+
];
|
|
142
267
|
// Stable module-level finalizers so `fetch`/`resolveNode` allocate no per-request closures.
|
|
143
268
|
const IDENTITY_RESPONSE = (response) => webResponseOf(response);
|
|
144
269
|
const RESPONSE_TIMEOUT = () => jsonError(503, "request_timeout");
|
|
@@ -231,6 +356,10 @@ export class Server {
|
|
|
231
356
|
/** WebSocket routes, matched separately at upgrade time (a GET + `Upgrade: websocket`). */
|
|
232
357
|
wsRouter;
|
|
233
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;
|
|
234
363
|
/** In-process pub/sub backing `ws.subscribe(topic)` + `app.publish(topic, data)` (single-instance).
|
|
235
364
|
* Created by the first `app.ws()` via the `@nifrajs/core/ws` runtime - `undefined` until then, so a
|
|
236
365
|
* no-WebSocket app never constructs (or bundles) it. */
|
|
@@ -240,7 +369,8 @@ export class Server {
|
|
|
240
369
|
* rather than delegate to Bun's native `server.publish`. */
|
|
241
370
|
wsHasValidatedSend = false;
|
|
242
371
|
/** Bun's native topic broadcast, bound by `listen()` in native-pubsub mode; `app.publish` uses it
|
|
243
|
-
* 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. */
|
|
244
374
|
nativePublish;
|
|
245
375
|
maxBodyBytes;
|
|
246
376
|
protoPoisoning;
|
|
@@ -248,6 +378,7 @@ export class Server {
|
|
|
248
378
|
trustBodyFraming;
|
|
249
379
|
wsMaxPayloadBytes;
|
|
250
380
|
requestTimeoutMs;
|
|
381
|
+
wsUpgradeTimeoutMs;
|
|
251
382
|
/** Installed by the `responseContract()` plugin; `undefined` = not installed, which is the default
|
|
252
383
|
* and the state in which a declared `response` schema stays a compile-time contract only. */
|
|
253
384
|
responseContractRuntime;
|
|
@@ -259,6 +390,9 @@ export class Server {
|
|
|
259
390
|
gracefulSignals;
|
|
260
391
|
stopHooks;
|
|
261
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;
|
|
262
396
|
/** Capacity-admission gate; `undefined` = off (the request path pays nothing). */
|
|
263
397
|
capacityGate;
|
|
264
398
|
onCapabilityUse;
|
|
@@ -272,6 +406,8 @@ export class Server {
|
|
|
272
406
|
idempotencyRuntime;
|
|
273
407
|
/** Installed opt-in runtime for `.tool()`/`.resource()`/`.prompt()`; `undefined` until `.use(mcp())`. */
|
|
274
408
|
mcpRuntime;
|
|
409
|
+
/** Installed answer for a request no route matched; `undefined` (the plain 404) until `.use(notFound())`. */
|
|
410
|
+
notFoundLane;
|
|
275
411
|
/** Installed Node-direct renderer for direct `resolveNode()` callers; `undefined` until `.use(nodeDirect())`. */
|
|
276
412
|
nodeOutcomeRuntime;
|
|
277
413
|
/** Installed streaming runtime for `.sse()` routes; `undefined` until `.use(streaming())`. */
|
|
@@ -286,6 +422,8 @@ export class Server {
|
|
|
286
422
|
bunServer;
|
|
287
423
|
sealed;
|
|
288
424
|
derives;
|
|
425
|
+
/** Dedicated authentication stages captured by subsequent routes. */
|
|
426
|
+
authStages;
|
|
289
427
|
decorations;
|
|
290
428
|
beforeHandleHooks;
|
|
291
429
|
afterHandleHooks;
|
|
@@ -295,10 +433,14 @@ export class Server {
|
|
|
295
433
|
onNodeRequestHooks;
|
|
296
434
|
/** Registration-time eligibility for the Node request twin lane. */
|
|
297
435
|
nodeRequestHooksComplete;
|
|
436
|
+
/** True when every request hook is safe to skip on a Bun native route. */
|
|
437
|
+
bunNativeRequestHooksSafe;
|
|
298
438
|
onResponseHooks;
|
|
299
439
|
onNodeResponseHooks;
|
|
300
440
|
/** Registration-time eligibility for the Node response twin lane. */
|
|
301
441
|
nodeResponseHooksComplete;
|
|
442
|
+
/** True when every response hook only mutates headers and can run after a Bun fused response. */
|
|
443
|
+
bunNativeResponseHeadersOnly;
|
|
302
444
|
/**
|
|
303
445
|
* True once a bundle registers a RAW `onNodeResponse` twin - one handed the outcome's header
|
|
304
446
|
* record itself rather than the case-normalizing view. Their names are contractually the wire
|
|
@@ -327,7 +469,7 @@ export class Server {
|
|
|
327
469
|
responseSources;
|
|
328
470
|
/** Memoized NodeRequestContext per plain-`Request` source - see {@link nodeRequestContextOf}. */
|
|
329
471
|
nodeContexts;
|
|
330
|
-
/**
|
|
472
|
+
/** Plugins/middleware already applied via `use`, by name - for idempotent dedupe. */
|
|
331
473
|
appliedPlugins;
|
|
332
474
|
/** Order-scoped evidence captured by routes registered after an assured plugin. */
|
|
333
475
|
activeAssurance;
|
|
@@ -336,7 +478,14 @@ export class Server {
|
|
|
336
478
|
/** App-declared MCP resources / prompts (via {@link resource} / {@link prompt}), read by `nifra mcp`. */
|
|
337
479
|
mcpResourceList;
|
|
338
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;
|
|
339
486
|
constructor(options = {}) {
|
|
487
|
+
this.options = options;
|
|
488
|
+
this.routePrefix = "";
|
|
340
489
|
this.catalog = new RouteCatalog();
|
|
341
490
|
this.wsRouter = new Router();
|
|
342
491
|
this.wsRouteCount = 0;
|
|
@@ -350,6 +499,7 @@ export class Server {
|
|
|
350
499
|
this.maxBodyBytes = maxBodyBytes;
|
|
351
500
|
this.wsMaxPayloadBytes = wsMaxPayloadBytes;
|
|
352
501
|
this.requestTimeoutMs = options.requestTimeoutMs ?? 0;
|
|
502
|
+
this.wsUpgradeTimeoutMs = options.wsUpgradeTimeoutMs ?? DEFAULT_WS_UPGRADE_TIMEOUT_MS;
|
|
353
503
|
this.clientIpTrust = options.clientIp;
|
|
354
504
|
this.acceptInboundDeadlines = options.acceptInboundDeadlines ?? false;
|
|
355
505
|
this.maxInboundDeadlineMs = options.maxInboundDeadlineMs ?? 30_000;
|
|
@@ -360,12 +510,17 @@ export class Server {
|
|
|
360
510
|
if (!Number.isFinite(this.requestTimeoutMs) || this.requestTimeoutMs < 0) {
|
|
361
511
|
throw new RangeError("requestTimeoutMs must be a finite non-negative number");
|
|
362
512
|
}
|
|
513
|
+
if (!Number.isFinite(this.wsUpgradeTimeoutMs) || this.wsUpgradeTimeoutMs < 0) {
|
|
514
|
+
throw new RangeError("wsUpgradeTimeoutMs must be a finite non-negative number");
|
|
515
|
+
}
|
|
363
516
|
if (!Number.isFinite(this.maxInboundDeadlineMs) || this.maxInboundDeadlineMs <= 0) {
|
|
364
517
|
throw new RangeError("maxInboundDeadlineMs must be a finite positive number");
|
|
365
518
|
}
|
|
366
519
|
this.gracefulSignals = options.gracefulSignals ?? false;
|
|
367
520
|
this.stopHooks = [];
|
|
368
521
|
this.fetchMounts = [];
|
|
522
|
+
this.mountOrder = 0;
|
|
523
|
+
this.preRouteMountCount = 0;
|
|
369
524
|
this.capacityGate = options.admission;
|
|
370
525
|
this.onCapabilityUse = options.onCapabilityUse;
|
|
371
526
|
this.capabilityInterceptors = [];
|
|
@@ -388,6 +543,7 @@ export class Server {
|
|
|
388
543
|
beginHookAudit(this, options.unusedScopedHooks ?? "warn", options.logger !== undefined);
|
|
389
544
|
}
|
|
390
545
|
this.derives = [];
|
|
546
|
+
this.authStages = [];
|
|
391
547
|
this.decorations = {};
|
|
392
548
|
this.beforeHandleHooks = [];
|
|
393
549
|
this.afterHandleHooks = [];
|
|
@@ -396,9 +552,11 @@ export class Server {
|
|
|
396
552
|
this.onRequestHooks = [];
|
|
397
553
|
this.onNodeRequestHooks = [];
|
|
398
554
|
this.nodeRequestHooksComplete = true;
|
|
555
|
+
this.bunNativeRequestHooksSafe = true;
|
|
399
556
|
this.onResponseHooks = [];
|
|
400
557
|
this.onNodeResponseHooks = [];
|
|
401
558
|
this.nodeResponseHooksComplete = true;
|
|
559
|
+
this.bunNativeResponseHeadersOnly = true;
|
|
402
560
|
this.hasRawNodeResponseHook = false;
|
|
403
561
|
this.onResponseFinalizedHooks = [];
|
|
404
562
|
this.staticResponseHeaders = undefined;
|
|
@@ -409,7 +567,7 @@ export class Server {
|
|
|
409
567
|
this.responseRequests = new WeakMap();
|
|
410
568
|
this.responseSources = new WeakMap();
|
|
411
569
|
this.nodeContexts = new WeakMap();
|
|
412
|
-
this.appliedPlugins = new
|
|
570
|
+
this.appliedPlugins = new Map();
|
|
413
571
|
this.activeAssurance = [];
|
|
414
572
|
this.globalAssurance = [];
|
|
415
573
|
this.mcpResourceList = [];
|
|
@@ -417,7 +575,7 @@ export class Server {
|
|
|
417
575
|
}
|
|
418
576
|
assertConfigurable(operation) {
|
|
419
577
|
if (this.sealed) {
|
|
420
|
-
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`);
|
|
421
579
|
}
|
|
422
580
|
}
|
|
423
581
|
/** Register a callback awaited after graceful drain and server stop. Hung callbacks are bounded. */
|
|
@@ -441,9 +599,70 @@ export class Server {
|
|
|
441
599
|
path: normalizeMountPrefix(path),
|
|
442
600
|
handler: handler,
|
|
443
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,
|
|
444
607
|
};
|
|
445
608
|
this.fetchMounts.push(mount);
|
|
446
|
-
this.fetchMounts.sort(
|
|
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,
|
|
661
|
+
};
|
|
662
|
+
this.fetchMounts.push(mount);
|
|
663
|
+
this.fetchMounts.sort(compareMounts);
|
|
664
|
+
this.preRouteMountCount += 1;
|
|
665
|
+
wsChanges += 1;
|
|
447
666
|
return this;
|
|
448
667
|
}
|
|
449
668
|
/**
|
|
@@ -469,6 +688,30 @@ export class Server {
|
|
|
469
688
|
* by none before; one added after the last route reaches nothing
|
|
470
689
|
* (see {@link ServerOptions.unusedScopedHooks}).
|
|
471
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
|
+
}
|
|
472
715
|
decorate(key, value) {
|
|
473
716
|
this.assertConfigurable("decorate()");
|
|
474
717
|
this.decorations[key] = value;
|
|
@@ -485,6 +728,7 @@ export class Server {
|
|
|
485
728
|
this.onRequestHooks.push(fn);
|
|
486
729
|
this.onNodeRequestHooks.push(undefined);
|
|
487
730
|
this.nodeRequestHooksComplete = false;
|
|
731
|
+
this.bunNativeRequestHooksSafe = false;
|
|
488
732
|
return this;
|
|
489
733
|
}
|
|
490
734
|
/**
|
|
@@ -569,6 +813,7 @@ export class Server {
|
|
|
569
813
|
this.onResponseHooks.push(fn);
|
|
570
814
|
this.onNodeResponseHooks.push(undefined);
|
|
571
815
|
this.nodeResponseHooksComplete = false;
|
|
816
|
+
this.bunNativeResponseHeadersOnly = false;
|
|
572
817
|
return this;
|
|
573
818
|
}
|
|
574
819
|
/**
|
|
@@ -585,7 +830,8 @@ export class Server {
|
|
|
585
830
|
* app.responseHeaders({ "x-frame-options": "DENY", "referrer-policy": "no-referrer" })
|
|
586
831
|
*
|
|
587
832
|
* They are DEFAULTS: a value the request itself produced (`c.set.headers`, or a response hook)
|
|
588
|
-
* 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
|
|
589
835
|
* name, `__proto__`, or a name the render owns (`content-type`, `content-length`,
|
|
590
836
|
* `transfer-encoding`, `set-cookie`) throws a `TypeError` at wire-up.
|
|
591
837
|
*
|
|
@@ -617,7 +863,7 @@ export class Server {
|
|
|
617
863
|
// serialized-body marker survives for later `onResponseBody`/`onResponseRaw` observers and the
|
|
618
864
|
// Node writer; reconstructing a `Response` here would strip that marker and reclassify the Node
|
|
619
865
|
// outcome from `json` to a generic `response`. A guarded foreign response takes its clone path.
|
|
620
|
-
const statics = buildStaticResponseHeaders(record);
|
|
866
|
+
const statics = buildStaticResponseHeaders(record, this.inheritedStatics);
|
|
621
867
|
this.onResponseHooks.push((response) => applyStaticResponseHeaders(response, statics));
|
|
622
868
|
this.onNodeResponseHooks.push(undefined);
|
|
623
869
|
this.nodeResponseHooksComplete = false;
|
|
@@ -626,7 +872,7 @@ export class Server {
|
|
|
626
872
|
const merged = this.staticResponseHeaders === undefined
|
|
627
873
|
? record
|
|
628
874
|
: { ...this.staticResponseHeaders.record, ...record };
|
|
629
|
-
this.staticResponseHeaders = buildStaticResponseHeaders(merged);
|
|
875
|
+
this.staticResponseHeaders = buildStaticResponseHeaders(merged, this.inheritedStatics);
|
|
630
876
|
const statics = this.staticResponseHeaders;
|
|
631
877
|
this.wrapWebResponse = (response) => applyStaticResponseHeaders(webResponseOf(response), statics);
|
|
632
878
|
this.webResponseTimeout = () => applyStaticResponseHeaders(RESPONSE_TIMEOUT(), statics);
|
|
@@ -654,24 +900,36 @@ export class Server {
|
|
|
654
900
|
this.onResponseFinalizedHooks.push(fn);
|
|
655
901
|
return this;
|
|
656
902
|
}
|
|
657
|
-
use(
|
|
903
|
+
use(input) {
|
|
904
|
+
// A method-routes plugin is an ordinary plugin function; only its type is opaque.
|
|
905
|
+
const arg = input;
|
|
658
906
|
this.assertConfigurable("use()");
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
if (name
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
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
|
|
665
917
|
}
|
|
918
|
+
this.appliedPlugins.set(name, arg);
|
|
919
|
+
}
|
|
920
|
+
if (typeof arg === "function") {
|
|
666
921
|
const evidence = assuranceDeclarationsOf(arg);
|
|
667
922
|
const pluginOnly = evidence.filter((item) => item.scope === "plugin");
|
|
668
923
|
this.globalAssurance.push(...evidence.filter((item) => item.scope === "global"));
|
|
669
924
|
this.activeAssurance.push(...evidence.filter((item) => item.scope === "subsequent"));
|
|
670
925
|
this.activeAssurance.push(...pluginOnly);
|
|
926
|
+
const inherited = this.inheritedPlugins;
|
|
927
|
+
this.inheritedPlugins = undefined;
|
|
671
928
|
try {
|
|
672
929
|
return arg(this);
|
|
673
930
|
}
|
|
674
931
|
finally {
|
|
932
|
+
this.inheritedPlugins = inherited;
|
|
675
933
|
// Remove only this plugin's temporary evidence. Nested assured plugins may deliberately leave
|
|
676
934
|
// subsequent evidence active, so truncating the whole array would lose real ordering semantics.
|
|
677
935
|
for (const item of pluginOnly) {
|
|
@@ -681,11 +939,6 @@ export class Server {
|
|
|
681
939
|
}
|
|
682
940
|
}
|
|
683
941
|
}
|
|
684
|
-
if (arg.name !== undefined) {
|
|
685
|
-
if (this.appliedPlugins.has(arg.name))
|
|
686
|
-
return this;
|
|
687
|
-
this.appliedPlugins.add(arg.name);
|
|
688
|
-
}
|
|
689
942
|
const evidence = assuranceDeclarationsOf(arg);
|
|
690
943
|
if (evidence.some((item) => item.scope === "plugin")) {
|
|
691
944
|
throw new Error('route assurance: scope "plugin" may only annotate a plugin function');
|
|
@@ -707,6 +960,9 @@ export class Server {
|
|
|
707
960
|
this.onNodeRequestHooks.push(arg.onNodeRequest);
|
|
708
961
|
if (arg.onNodeRequest === undefined)
|
|
709
962
|
this.nodeRequestHooksComplete = false;
|
|
963
|
+
if (!arg[BUN_NATIVE_REQUEST_SAFE]) {
|
|
964
|
+
this.bunNativeRequestHooksSafe = false;
|
|
965
|
+
}
|
|
710
966
|
}
|
|
711
967
|
else if (arg.onNodeRequest !== undefined) {
|
|
712
968
|
throw new TypeError("onNodeRequest() requires a paired onRequest() hook");
|
|
@@ -725,6 +981,7 @@ export class Server {
|
|
|
725
981
|
this.nodeResponseHooksComplete = false;
|
|
726
982
|
else
|
|
727
983
|
this.hasRawNodeResponseHook = true;
|
|
984
|
+
this.bunNativeResponseHeadersOnly = false;
|
|
728
985
|
}
|
|
729
986
|
else if (arg.onNodeResponse !== undefined) {
|
|
730
987
|
throw new TypeError("onNodeResponse() requires a paired onResponse() hook");
|
|
@@ -733,10 +990,18 @@ export class Server {
|
|
|
733
990
|
// defaults its hook may then override, which is the order a single bundle reads in.
|
|
734
991
|
if (arg.responseHeaders !== undefined)
|
|
735
992
|
this.responseHeaders(arg.responseHeaders);
|
|
736
|
-
if (arg.onResponseHeaders !== undefined)
|
|
737
|
-
this.responseObserverMethods().onResponseHeaders(arg.onResponseHeaders);
|
|
738
|
-
|
|
739
|
-
|
|
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
|
+
}
|
|
740
1005
|
if (arg.onResponseRaw !== undefined)
|
|
741
1006
|
this.responseObserverMethods().onResponseRaw(arg.onResponseRaw);
|
|
742
1007
|
if (arg.onResponseFinalized !== undefined)
|
|
@@ -855,10 +1120,11 @@ export class Server {
|
|
|
855
1120
|
this.topics ??= runtime.createTopics();
|
|
856
1121
|
// A `messageSchema` wraps `message` with validation once, here - every adapter then dispatches
|
|
857
1122
|
// already-validated, typed messages (Bun/Deno/Node/Workers) with no per-adapter code.
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
1123
|
+
const entry = { handler: runtime.wrapHandler(handler, this.protoPoisoning) };
|
|
1124
|
+
for (const form of expandOptionalParams(path))
|
|
1125
|
+
this.wsRouter.add("GET", form, entry);
|
|
861
1126
|
this.wsRouteCount += 1;
|
|
1127
|
+
wsChanges += 1;
|
|
862
1128
|
return this;
|
|
863
1129
|
}
|
|
864
1130
|
/**
|
|
@@ -898,16 +1164,37 @@ export class Server {
|
|
|
898
1164
|
* is the "compiled", order-scoped per-route chain.
|
|
899
1165
|
*/
|
|
900
1166
|
register(method, path, schema, handler) {
|
|
901
|
-
this.
|
|
902
|
-
this.catalog.add(this.prepareRoute(method, path, schema, handler));
|
|
1167
|
+
this.registerBatch([{ method, path, schema, handler }]);
|
|
903
1168
|
}
|
|
904
1169
|
/** Register a contract/group route batch atomically. Every route captures the same current chain it
|
|
905
|
-
* 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. */
|
|
906
1174
|
registerBatch(routes) {
|
|
907
1175
|
this.assertConfigurable("route registration");
|
|
908
|
-
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
|
+
}
|
|
909
1182
|
this.catalog.addBatch(staged);
|
|
910
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
|
+
}
|
|
911
1198
|
prepareRoute(method, path, schema, handler) {
|
|
912
1199
|
const compiled = compileRouteOptions({
|
|
913
1200
|
maxBodyBytes: this.maxBodyBytes,
|
|
@@ -921,6 +1208,7 @@ export class Server {
|
|
|
921
1208
|
idempotencyRuntime: this.idempotencyRuntime,
|
|
922
1209
|
responseContractRuntime: this.responseContractRuntime,
|
|
923
1210
|
derives: this.derives,
|
|
1211
|
+
authStages: this.authStages,
|
|
924
1212
|
beforeHandleHooks: this.beforeHandleHooks,
|
|
925
1213
|
afterHandleHooks: this.afterHandleHooks,
|
|
926
1214
|
onErrorHooks: this.onErrorHooks,
|
|
@@ -928,16 +1216,30 @@ export class Server {
|
|
|
928
1216
|
defaultOnValidationError: this.defaultOnValidationError,
|
|
929
1217
|
}, method, path, schema, handler);
|
|
930
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
|
+
}
|
|
931
1222
|
const { bare, fusedQuery, fusedBody } = lanes;
|
|
932
|
-
const fusedBodyRunner = fusedBody
|
|
933
|
-
? this.buildFusedBodyRunner(handler, schema?.body, hasDecorations ? routeDecorations : undefined, bodyLimit ?? UNLIMITED_BODY_BYTES)
|
|
934
|
-
: undefined;
|
|
935
1223
|
// Fused lifecycle lanes: derive + before (with or without an after), and body + the same shape.
|
|
936
1224
|
// The lane selectors in `selectRouteLanes` are exhaustive about the lifecycleHookLane /
|
|
937
1225
|
// body-derive-before-after eligibility, so we only need to check the lane and the hook counts.
|
|
938
1226
|
const lifecycleHookLane = lanes.lifecycleHookLane;
|
|
939
1227
|
const isFusedLifecycle = lifecycleHookLane !== undefined && this.derives.length === 1;
|
|
940
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;
|
|
941
1243
|
const fusedWeb = bare && this.aroundHooks.length === 0
|
|
942
1244
|
? this.buildFusedWeb(handler, hasDecorations ? routeDecorations : undefined, isContextlessNoArgArrow(handler), bodyLimit ?? UNLIMITED_BODY_BYTES)
|
|
943
1245
|
: fusedQuery
|
|
@@ -955,6 +1257,7 @@ export class Server {
|
|
|
955
1257
|
schema,
|
|
956
1258
|
handler: handler,
|
|
957
1259
|
derives: this.derives,
|
|
1260
|
+
authStages: compiled.authStages,
|
|
958
1261
|
beforeHandle: this.beforeHandleHooks,
|
|
959
1262
|
afterHandle: this.afterHandleHooks,
|
|
960
1263
|
onError: this.onErrorHooks,
|
|
@@ -982,6 +1285,7 @@ export class Server {
|
|
|
982
1285
|
ledgered,
|
|
983
1286
|
responseContract,
|
|
984
1287
|
derives: [...this.derives],
|
|
1288
|
+
authStages: [...compiled.authStages],
|
|
985
1289
|
decorations: routeDecorations,
|
|
986
1290
|
hasDecorations,
|
|
987
1291
|
beforeHandle: [...this.beforeHandleHooks],
|
|
@@ -996,6 +1300,9 @@ export class Server {
|
|
|
996
1300
|
method,
|
|
997
1301
|
path,
|
|
998
1302
|
schema,
|
|
1303
|
+
...(responseContract === undefined
|
|
1304
|
+
? {}
|
|
1305
|
+
: { responseContract: responseContract.runtime.mode }),
|
|
999
1306
|
...(capabilities.length > 0 ? { capabilities } : {}),
|
|
1000
1307
|
...(schema?.family === true ? { family: true } : {}),
|
|
1001
1308
|
};
|
|
@@ -1023,11 +1330,13 @@ export class Server {
|
|
|
1023
1330
|
this.assertConfigurable("responseObserver()");
|
|
1024
1331
|
const methods = runtime.install({
|
|
1025
1332
|
assertConfigurable: (operation) => this.assertConfigurable(operation),
|
|
1026
|
-
addResponseHook: (web, node) => {
|
|
1333
|
+
addResponseHook: (web, node, headerOnly = false) => {
|
|
1027
1334
|
this.onResponseHooks.push(web);
|
|
1028
1335
|
this.onNodeResponseHooks.push(node);
|
|
1029
1336
|
if (node === undefined)
|
|
1030
1337
|
this.nodeResponseHooksComplete = false;
|
|
1338
|
+
if (!headerOnly)
|
|
1339
|
+
this.bunNativeResponseHeadersOnly = false;
|
|
1031
1340
|
},
|
|
1032
1341
|
enableResponseBodyTagging: () => {
|
|
1033
1342
|
if (this.responseBodyTag === undefined) {
|
|
@@ -1051,6 +1360,11 @@ export class Server {
|
|
|
1051
1360
|
this.assertConfigurable("mcp()");
|
|
1052
1361
|
this.mcpRuntime = runtime;
|
|
1053
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
|
+
}
|
|
1054
1368
|
/** @internal Symbol-keyed install seam for the `nodeDirect()` plugin. Off the public typed surface. */
|
|
1055
1369
|
[INSTALL_NODE_DIRECT](runtime) {
|
|
1056
1370
|
this.assertConfigurable("nodeDirect()");
|
|
@@ -1066,6 +1380,41 @@ export class Server {
|
|
|
1066
1380
|
this.assertConfigurable("websocket()");
|
|
1067
1381
|
this.wsRuntime = runtime;
|
|
1068
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
|
+
}
|
|
1069
1418
|
/**
|
|
1070
1419
|
* Merge another server's routes into this one - the composition escape hatch for large apps.
|
|
1071
1420
|
*
|
|
@@ -1092,75 +1441,188 @@ export class Server {
|
|
|
1092
1441
|
*/
|
|
1093
1442
|
merge(other) {
|
|
1094
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
|
+
}
|
|
1095
1454
|
const source = other;
|
|
1096
1455
|
if (source.wsRouteCount > 0) {
|
|
1097
1456
|
throw new RouteConfigError("INVALID_PATH", "merge() does not carry WebSocket routes - register .ws() routes on the parent server");
|
|
1098
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) {
|
|
1099
1555
|
const sourceRoutes = source.catalog.entries();
|
|
1100
1556
|
// Hooks and global assurance stay LOCAL to a group that has routes; a route-less group is a
|
|
1101
1557
|
// middleware bundle whose hooks can only mean app-wide intent, so it keeps the global append.
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
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
|
|
1105
1563
|
// enforcement for parent routes the group's hooks do not see.
|
|
1106
|
-
const foldedAssurance = scoped &&
|
|
1564
|
+
const foldedAssurance = scoped && ownGlobal.length > 0
|
|
1107
1565
|
? (route) => ({
|
|
1108
1566
|
...route,
|
|
1109
|
-
assurance: [...route.assurance, ...
|
|
1567
|
+
assurance: [...route.assurance, ...ownGlobal],
|
|
1110
1568
|
})
|
|
1111
1569
|
: (route) => route;
|
|
1112
1570
|
this.catalog.addBatch(sourceRoutes.map((route) => this.bindFusedRuntime(foldedAssurance(route))));
|
|
1113
1571
|
// Resolved idempotency/ledger route entries carry their own store/sink configuration, while the
|
|
1114
|
-
// runtime object supplies the generic execution machinery.
|
|
1115
|
-
//
|
|
1116
|
-
//
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
const scope = new RouteCatalog();
|
|
1130
|
-
scope.addBatch(sourceRoutes);
|
|
1131
|
-
this.onRequestHooks.push(...source.onRequestHooks.map((hook) => (req, platform) => scope.find(req.method, pathnameOf(req.url)).found ? hook(req, platform) : undefined));
|
|
1132
|
-
// An `undefined` slot marks an unpaired hook (position-aligned with `onRequestHooks`) and
|
|
1133
|
-
// must stay `undefined` - wrapping it would fabricate a Node twin that never existed.
|
|
1134
|
-
this.onNodeRequestHooks.push(...source.onNodeRequestHooks.map((hook) => hook === undefined
|
|
1135
|
-
? undefined
|
|
1136
|
-
: (req, platform) => scope.find(req.method, pathnameOf(req.url)).found ? hook(req, platform) : undefined));
|
|
1137
|
-
}
|
|
1138
|
-
else {
|
|
1139
|
-
this.onRequestHooks.push(...source.onRequestHooks);
|
|
1140
|
-
this.onNodeRequestHooks.push(...source.onNodeRequestHooks);
|
|
1141
|
-
}
|
|
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));
|
|
1142
1587
|
this.nodeRequestHooksComplete &&= source.nodeRequestHooksComplete;
|
|
1143
|
-
|
|
1144
|
-
//
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
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));
|
|
1151
1611
|
this.nodeResponseHooksComplete &&= source.nodeResponseHooksComplete;
|
|
1612
|
+
this.bunNativeResponseHeadersOnly &&= source.bunNativeResponseHeadersOnly;
|
|
1152
1613
|
this.hasRawNodeResponseHook ||= source.hasRawNodeResponseHook;
|
|
1153
|
-
this.onResponseFinalizedHooks.push(...source.onResponseFinalizedHooks);
|
|
1154
1614
|
if (source.responseBodyTag !== undefined) {
|
|
1155
1615
|
const owner = this.enableResponseBodyTagging();
|
|
1156
1616
|
this.responseBodyOwners.add(source.responseBodyTag);
|
|
1157
1617
|
source.responseBodyOwners.add(owner);
|
|
1158
1618
|
}
|
|
1159
1619
|
if (!scoped)
|
|
1160
|
-
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);
|
|
1161
1624
|
this.mcpResourceList.push(...source.mcpResourceList);
|
|
1162
1625
|
this.mcpPromptList.push(...source.mcpPromptList);
|
|
1163
|
-
return this;
|
|
1164
1626
|
}
|
|
1165
1627
|
/** A fused renderer closes over runtime services to keep its seven-argument JSC fast path. Merging
|
|
1166
1628
|
* rebinds that closure once to the executing server; generic plans already receive the runtime. */
|
|
@@ -1173,15 +1635,30 @@ export class Server {
|
|
|
1173
1635
|
const lane = entry.execution.fusedLane;
|
|
1174
1636
|
// Type-erase the lane so each `===` branch sees the full union, not the narrowed remainder.
|
|
1175
1637
|
const laneName = lane;
|
|
1176
|
-
// Body+lifecycle routes have a fused Web renderer,
|
|
1177
|
-
//
|
|
1178
|
-
//
|
|
1179
|
-
|
|
1180
|
-
? this.buildFusedBodyRunner(entry.handler, entry.schema?.body, entry.hasDecorations ? entry.decorations : undefined, entry.bodyLimit ?? UNLIMITED_BODY_BYTES)
|
|
1181
|
-
: 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.
|
|
1182
1642
|
const derive = entry.derives[0];
|
|
1183
1643
|
const before = entry.beforeHandle[0];
|
|
1184
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;
|
|
1185
1662
|
const fusedWeb = laneName === "body"
|
|
1186
1663
|
? this.buildFusedBodyWeb(fusedBody)
|
|
1187
1664
|
: laneName === "query"
|
|
@@ -1231,6 +1708,10 @@ export class Server {
|
|
|
1231
1708
|
* Bun/Node/Deno omit it (then `c.env` is `undefined` and `c.waitUntil` runs fire-and-forget).
|
|
1232
1709
|
*/
|
|
1233
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);
|
|
1234
1715
|
// An edge deployment whose only ingress is this method declares its requests runtime-framed at
|
|
1235
1716
|
// construction; everything else stays on the delivered-byte check (see `trustBodyFraming`).
|
|
1236
1717
|
if (this.trustBodyFraming)
|
|
@@ -1266,13 +1747,14 @@ export class Server {
|
|
|
1266
1747
|
}
|
|
1267
1748
|
/** Web response path when Bun already matched the route. The lifecycle and response hooks remain
|
|
1268
1749
|
* exactly the same as {@link fetchSource}; only portable URL scanning + trie lookup are skipped. */
|
|
1269
|
-
fetchMatched(source, entry, params) {
|
|
1270
|
-
if (this.capacityGate === undefined)
|
|
1271
|
-
return this.fetchMatchedInner(source, entry, params);
|
|
1272
|
-
|
|
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));
|
|
1273
1755
|
}
|
|
1274
|
-
fetchMatchedInner(source, entry, params) {
|
|
1275
|
-
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);
|
|
1276
1758
|
if (this.onResponseHooks.length === 0 && this.onResponseFinalizedHooks.length === 0) {
|
|
1277
1759
|
return outcome;
|
|
1278
1760
|
}
|
|
@@ -1338,19 +1820,26 @@ export class Server {
|
|
|
1338
1820
|
}
|
|
1339
1821
|
const parts = source.urlParts ?? urlPartsOf(source.url);
|
|
1340
1822
|
const match = this.catalog.find(source.method, parts.pathname);
|
|
1341
|
-
if (match.found)
|
|
1823
|
+
if (match.found || !isRoutableMethod(source.method))
|
|
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)
|
|
1342
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) {
|
|
1343
1840
|
for (const mount of this.fetchMounts) {
|
|
1344
|
-
if (
|
|
1345
|
-
|
|
1346
|
-
const candidate = mount.handler[NODE_NATIVE_MOUNT];
|
|
1347
|
-
return typeof candidate === "function"
|
|
1348
|
-
? {
|
|
1349
|
-
handler: candidate,
|
|
1350
|
-
path: mount.path,
|
|
1351
|
-
stripPrefix: mount.stripPrefix,
|
|
1352
|
-
}
|
|
1353
|
-
: undefined;
|
|
1841
|
+
if (mount.beforeRoutes === beforeRoutes && underMountPrefix(pathname, mount.path))
|
|
1842
|
+
return mount;
|
|
1354
1843
|
}
|
|
1355
1844
|
return undefined;
|
|
1356
1845
|
}
|
|
@@ -1489,6 +1978,7 @@ export class Server {
|
|
|
1489
1978
|
const context = {
|
|
1490
1979
|
status: outcome.status,
|
|
1491
1980
|
headers,
|
|
1981
|
+
headersAreLowercase: !this.hasRawNodeResponseHook && lowercaseKeys,
|
|
1492
1982
|
cookies: outcome.kind === "json" ? outcome.cookies : undefined,
|
|
1493
1983
|
body: outcome.body,
|
|
1494
1984
|
};
|
|
@@ -1570,76 +2060,210 @@ export class Server {
|
|
|
1570
2060
|
* `data`). Runs the route's `upgrade(c)` guard in a real request context. Synchronous unless
|
|
1571
2061
|
* `upgrade()` is async; a throw rejects with a flat 500 (no detail leaked).
|
|
1572
2062
|
*/
|
|
2063
|
+
runWebSocketRequestHooks(req, platform) {
|
|
2064
|
+
let current = req;
|
|
2065
|
+
for (let i = 0; i < this.onRequestHooks.length; i++) {
|
|
2066
|
+
const hook = this.onRequestHooks[i];
|
|
2067
|
+
if (hook === undefined)
|
|
2068
|
+
return current;
|
|
2069
|
+
const outcome = hook(current, platform);
|
|
2070
|
+
if (outcome instanceof Promise) {
|
|
2071
|
+
return outcome.then((first) => this.continueWebSocketRequestHooks(first, i + 1, current, platform));
|
|
2072
|
+
}
|
|
2073
|
+
if (outcome instanceof Request) {
|
|
2074
|
+
current = outcome;
|
|
2075
|
+
continue;
|
|
2076
|
+
}
|
|
2077
|
+
if (outcome !== undefined)
|
|
2078
|
+
return outcome;
|
|
2079
|
+
}
|
|
2080
|
+
return current;
|
|
2081
|
+
}
|
|
2082
|
+
async continueWebSocketRequestHooks(first, start, current, platform) {
|
|
2083
|
+
let next = current;
|
|
2084
|
+
let outcome = first;
|
|
2085
|
+
for (let i = start;; i++) {
|
|
2086
|
+
if (outcome instanceof Request)
|
|
2087
|
+
next = outcome;
|
|
2088
|
+
else if (outcome !== undefined)
|
|
2089
|
+
return outcome;
|
|
2090
|
+
if (i >= this.onRequestHooks.length)
|
|
2091
|
+
return next;
|
|
2092
|
+
const hook = this.onRequestHooks[i];
|
|
2093
|
+
if (hook === undefined)
|
|
2094
|
+
return next;
|
|
2095
|
+
outcome = await hook(next, platform);
|
|
2096
|
+
}
|
|
2097
|
+
}
|
|
1573
2098
|
resolveWebSocketUpgrade(req, platform) {
|
|
1574
|
-
|
|
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())
|
|
1575
2102
|
return WS_PASS;
|
|
1576
2103
|
if (req.headers.get("upgrade")?.toLowerCase() !== "websocket")
|
|
1577
2104
|
return WS_PASS;
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
if (
|
|
1581
|
-
return WS_PASS;
|
|
1582
|
-
//
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
const
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
const
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
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);
|
|
2114
|
+
const timeoutMs = this.wsUpgradeTimeoutMs === 0
|
|
2115
|
+
? 0
|
|
2116
|
+
: this.requestTimeoutMs > 0
|
|
2117
|
+
? Math.min(this.wsUpgradeTimeoutMs, this.requestTimeoutMs)
|
|
2118
|
+
: this.wsUpgradeTimeoutMs;
|
|
2119
|
+
const controller = timeoutMs > 0 ? new AbortController() : undefined;
|
|
2120
|
+
const signal = controller?.signal ?? getNeverAbortSignal();
|
|
2121
|
+
const deadline = timeoutMs > 0 ? Date.now() + timeoutMs : undefined;
|
|
2122
|
+
const timeoutResponse = jsonError(503, "request_timeout");
|
|
2123
|
+
const onTimeout = () => ({
|
|
2124
|
+
kind: "reject",
|
|
2125
|
+
response: timeoutResponse,
|
|
2126
|
+
});
|
|
2127
|
+
const bounded = (work, onDone) => controller === undefined
|
|
2128
|
+
? work
|
|
2129
|
+
: this.withTimeout(work, controller, onDone, Math.max(0, deadline - Date.now()));
|
|
2130
|
+
const resolveMatched = (request) => {
|
|
2131
|
+
const url = urlPartsOf(request.url);
|
|
2132
|
+
const match = this.wsRouter.find("GET", url.pathname);
|
|
2133
|
+
if (!match.found)
|
|
2134
|
+
return WS_PASS; // upgrade header, no WS route here → normal routing decides
|
|
2135
|
+
// Inspect only captured values for escapes. Scanning the full pathname repeated work the router
|
|
2136
|
+
// already did and made every plain dynamic route pay for unrelated static path bytes.
|
|
2137
|
+
const params = match.params === EMPTY_PARAMS ? match.params : decodeRouteParams(match.params);
|
|
2138
|
+
if (params === null)
|
|
2139
|
+
return { kind: "reject", response: jsonError(400, "malformed_path") };
|
|
2140
|
+
const handler = match.payload.handler;
|
|
2141
|
+
// Non-null: wsRouteCount > 0 ⇒ ws() ran ⇒ `.use(websocket())` installed the runtime + registry.
|
|
2142
|
+
const pubsub = this.topics;
|
|
2143
|
+
const attach = this.wsRuntime.attach;
|
|
2144
|
+
// CSWSH guard, before any per-connection work or the user's upgrade(): reject a disallowed
|
|
2145
|
+
// Origin with 403. Browsers don't CORS-protect WS handshakes but do send cookies, so this
|
|
2146
|
+
// blocks cross-site authenticated sockets when the route opts in via `allowedOrigins`.
|
|
2147
|
+
const origin = request.headers.get("origin");
|
|
2148
|
+
if (handler.allowedOrigins !== undefined) {
|
|
2149
|
+
const allowed = typeof handler.allowedOrigins === "function"
|
|
2150
|
+
? handler.allowedOrigins(origin)
|
|
2151
|
+
: origin !== null && handler.allowedOrigins.includes(origin);
|
|
2152
|
+
if (!allowed)
|
|
2153
|
+
return { kind: "reject", response: jsonError(403, "forbidden_origin") };
|
|
2154
|
+
}
|
|
2155
|
+
else if (origin !== null && !wsSameOrigin(origin, request)) {
|
|
2156
|
+
// Secure default (no explicit `allowedOrigins`): reject a CROSS-ORIGIN browser handshake - the
|
|
2157
|
+
// CSWSH case, since browsers send cookies on WS handshakes and don't apply CORS. Non-browser
|
|
2158
|
+
// clients send no `Origin` and pass; same-origin browsers pass. Set `allowedOrigins` to permit
|
|
2159
|
+
// specific cross-origin clients (or `() => true` for a genuinely public socket).
|
|
1600
2160
|
return { kind: "reject", response: jsonError(403, "forbidden_origin") };
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
try {
|
|
1632
|
-
const result = handler.upgrade(ctx);
|
|
1633
|
-
return result instanceof Promise
|
|
1634
|
-
? result.then(settle, () => ({
|
|
2161
|
+
}
|
|
2162
|
+
if (handler.upgrade === undefined) {
|
|
2163
|
+
return {
|
|
2164
|
+
kind: "upgrade",
|
|
2165
|
+
handler,
|
|
2166
|
+
data: undefined,
|
|
2167
|
+
pubsub,
|
|
2168
|
+
attach,
|
|
2169
|
+
maxPayloadBytes: this.wsMaxPayloadBytes,
|
|
2170
|
+
};
|
|
2171
|
+
}
|
|
2172
|
+
const budget = controller === undefined
|
|
2173
|
+
? createUnboundedRequestBudget(signal)
|
|
2174
|
+
: createRequestBudget({ deadline: deadline, signal });
|
|
2175
|
+
const ctx = new RequestContext(request, params, url.search, signal, budget, platform, this.maxBodyBytes, this.protoPoisoning);
|
|
2176
|
+
const settle = (value) => value instanceof Response
|
|
2177
|
+
? { kind: "reject", response: value }
|
|
2178
|
+
: {
|
|
2179
|
+
kind: "upgrade",
|
|
2180
|
+
handler,
|
|
2181
|
+
data: value,
|
|
2182
|
+
pubsub,
|
|
2183
|
+
attach,
|
|
2184
|
+
maxPayloadBytes: this.wsMaxPayloadBytes,
|
|
2185
|
+
};
|
|
2186
|
+
try {
|
|
2187
|
+
const result = handler.upgrade(ctx);
|
|
2188
|
+
if (!(result instanceof Promise))
|
|
2189
|
+
return settle(result);
|
|
2190
|
+
const pending = result.then(settle, () => ({
|
|
1635
2191
|
kind: "reject",
|
|
1636
2192
|
response: jsonError(500, "internal_error"),
|
|
1637
|
-
}))
|
|
1638
|
-
|
|
2193
|
+
}));
|
|
2194
|
+
return bounded(pending, onTimeout);
|
|
2195
|
+
}
|
|
2196
|
+
catch {
|
|
2197
|
+
return { kind: "reject", response: jsonError(500, "internal_error") };
|
|
2198
|
+
}
|
|
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
|
+
};
|
|
2249
|
+
if (this.onRequestHooks.length === 0)
|
|
2250
|
+
return resolveWithDeadline(req);
|
|
2251
|
+
let hooked;
|
|
2252
|
+
try {
|
|
2253
|
+
hooked = this.runWebSocketRequestHooks(req, platform);
|
|
1639
2254
|
}
|
|
1640
2255
|
catch {
|
|
1641
2256
|
return { kind: "reject", response: jsonError(500, "internal_error") };
|
|
1642
2257
|
}
|
|
2258
|
+
if (!(hooked instanceof Promise)) {
|
|
2259
|
+
return hooked instanceof Response
|
|
2260
|
+
? { kind: "reject", response: hooked }
|
|
2261
|
+
: resolveWithDeadline(hooked);
|
|
2262
|
+
}
|
|
2263
|
+
const pending = hooked.then((result) => result instanceof Response
|
|
2264
|
+
? { kind: "reject", response: result }
|
|
2265
|
+
: resolveWithDeadline(result), () => ({ kind: "reject", response: jsonError(500, "internal_error") }));
|
|
2266
|
+
return bounded(Promise.resolve(pending).then((result) => result), onTimeout);
|
|
1643
2267
|
}
|
|
1644
2268
|
/** Bun `fetch` when WS routes exist: try a WS upgrade first, else run the normal HTTP lifecycle.
|
|
1645
2269
|
* `undefined` ⇒ Bun owns the upgraded socket; a `Response` ⇒ a normal reply or a rejected upgrade.
|
|
@@ -1650,11 +2274,13 @@ export class Server {
|
|
|
1650
2274
|
return this.fetch(req, bunPeerPlatform(server, req));
|
|
1651
2275
|
if (o.kind === "reject")
|
|
1652
2276
|
return o.response;
|
|
1653
|
-
return server.upgrade(req, {
|
|
2277
|
+
return server.upgrade(req, {
|
|
2278
|
+
data: { handler: o.handler, data: o.data, pubsub: o.pubsub },
|
|
2279
|
+
})
|
|
1654
2280
|
? undefined
|
|
1655
2281
|
: jsonError(426, "upgrade_required");
|
|
1656
2282
|
};
|
|
1657
|
-
const outcome = this.resolveWebSocketUpgrade(req);
|
|
2283
|
+
const outcome = this.resolveWebSocketUpgrade(req, bunPeerPlatform(server, req));
|
|
1658
2284
|
return outcome instanceof Promise ? outcome.then(handle) : handle(outcome);
|
|
1659
2285
|
}
|
|
1660
2286
|
/**
|
|
@@ -1670,6 +2296,12 @@ export class Server {
|
|
|
1670
2296
|
const derived = resolveClientIp(platform?.clientIp, requestOf(source), this.clientIpTrust);
|
|
1671
2297
|
return { ...platform, clientIp: derived };
|
|
1672
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
|
+
}
|
|
1673
2305
|
dispatch(source, platform, finalize, wrapResponse, onTimeout,
|
|
1674
2306
|
// True only from the Web `fetch` path - unlocks each route's fused lane, whose output type IS
|
|
1675
2307
|
// `Response` (`T = Response` there by construction; the node path always passes false).
|
|
@@ -1677,10 +2309,14 @@ export class Server {
|
|
|
1677
2309
|
// Resolve the trust declaration into the platform's `clientIp` ONCE, here at the shared funnel, so
|
|
1678
2310
|
// `c.clientIp` (and every hook/derive downstream) sees the derived caller. No config ⇒ the raw
|
|
1679
2311
|
// socket peer the adapter supplied passes through untouched (a one-property no-op on the hot path).
|
|
1680
|
-
|
|
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);
|
|
1681
2315
|
// onRequest hooks may be async, so a hooked app takes the async path; with no hooks (the common
|
|
1682
2316
|
// case) routing stays synchronous, letting a bare route resolve with no lifecycle promise at all.
|
|
1683
|
-
|
|
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)) {
|
|
1684
2320
|
return this.routeAndRun(source, resolved, finalize, wrapResponse, onTimeout, webFast);
|
|
1685
2321
|
}
|
|
1686
2322
|
if (!webFast && this.canUseNodeRequestHooks()) {
|
|
@@ -1736,11 +2372,14 @@ export class Server {
|
|
|
1736
2372
|
this.responseSources.set(source, originalRequest);
|
|
1737
2373
|
let current = source;
|
|
1738
2374
|
for (let i = 0; i < hooks.length; i++) {
|
|
1739
|
-
const
|
|
2375
|
+
const seen = requestOf(current);
|
|
2376
|
+
const outcome = hooks[i](seen, platform);
|
|
1740
2377
|
if (outcome instanceof Promise) {
|
|
1741
2378
|
return outcome.then((early) => this.continueOnRequest(early, i + 1, originalRequest, current, platform, finalize, wrapResponse, onTimeout, webFast));
|
|
1742
2379
|
}
|
|
1743
2380
|
if (outcome instanceof Request) {
|
|
2381
|
+
if (outcome !== seen)
|
|
2382
|
+
recordRequestReplacement(outcome, seen);
|
|
1744
2383
|
current = outcome;
|
|
1745
2384
|
if (outcome !== originalRequest)
|
|
1746
2385
|
this.responseRequests.set(originalRequest, outcome);
|
|
@@ -1755,10 +2394,13 @@ export class Server {
|
|
|
1755
2394
|
* the remaining hooks (awaiting freely - we're already async here). */
|
|
1756
2395
|
async continueOnRequest(first, nextIndex, originalRequest, sourceAtAwait, platform, finalize, wrapResponse, onTimeout, webFast) {
|
|
1757
2396
|
let current = sourceAtAwait;
|
|
2397
|
+
let seen = requestOf(sourceAtAwait);
|
|
1758
2398
|
let early = first;
|
|
1759
2399
|
let index = nextIndex;
|
|
1760
2400
|
for (;;) {
|
|
1761
2401
|
if (early instanceof Request) {
|
|
2402
|
+
if (early !== seen)
|
|
2403
|
+
recordRequestReplacement(early, seen);
|
|
1762
2404
|
current = early;
|
|
1763
2405
|
if (early !== originalRequest)
|
|
1764
2406
|
this.responseRequests.set(originalRequest, early);
|
|
@@ -1768,7 +2410,8 @@ export class Server {
|
|
|
1768
2410
|
}
|
|
1769
2411
|
if (index >= this.onRequestHooks.length)
|
|
1770
2412
|
break;
|
|
1771
|
-
|
|
2413
|
+
seen = requestOf(current);
|
|
2414
|
+
const outcome = this.onRequestHooks[index](seen, platform);
|
|
1772
2415
|
early = outcome instanceof Promise ? await outcome : outcome;
|
|
1773
2416
|
index++;
|
|
1774
2417
|
}
|
|
@@ -1884,9 +2527,20 @@ export class Server {
|
|
|
1884
2527
|
search = searchStart === -1 ? "" : rawUrl.slice(searchStart, searchEnd);
|
|
1885
2528
|
}
|
|
1886
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
|
+
}
|
|
1887
2541
|
const match = this.catalog.find(source.method, pathname);
|
|
1888
2542
|
if (!match.found) {
|
|
1889
|
-
const mounted = this.fetchMount(pathname, source, platform);
|
|
2543
|
+
const mounted = this.fetchMount(pathname, source, platform, false);
|
|
1890
2544
|
if (mounted !== undefined) {
|
|
1891
2545
|
return mounted instanceof Promise
|
|
1892
2546
|
? mounted.then((response) => wrapResponse(response))
|
|
@@ -1898,7 +2552,10 @@ export class Server {
|
|
|
1898
2552
|
// and every other name in that record is lowercase (the Web lane normalizes either way).
|
|
1899
2553
|
plainError(405, "method_not_allowed", { allow: match.allowed.join(", ") }));
|
|
1900
2554
|
}
|
|
1901
|
-
|
|
2555
|
+
const lane = this.notFoundLane;
|
|
2556
|
+
return lane === undefined
|
|
2557
|
+
? wrapResponse(plainError(404, "not_found"))
|
|
2558
|
+
: lane(this, source, pathname, platform, wrapResponse, onTimeout);
|
|
1902
2559
|
}
|
|
1903
2560
|
// Inspect only captured values for escapes. Scanning the full pathname repeated work the router
|
|
1904
2561
|
// already did and made every plain dynamic route pay for unrelated static path bytes.
|
|
@@ -1908,14 +2565,46 @@ export class Server {
|
|
|
1908
2565
|
}
|
|
1909
2566
|
return this.runMatched(source, platform, match.payload, params, search, finalize, wrapResponse, onTimeout, webFast);
|
|
1910
2567
|
}
|
|
1911
|
-
fetchMount(pathname, source, platform) {
|
|
1912
|
-
|
|
1913
|
-
|
|
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))
|
|
1914
2574
|
continue;
|
|
1915
|
-
|
|
1916
|
-
|
|
2575
|
+
first = i;
|
|
2576
|
+
break;
|
|
1917
2577
|
}
|
|
1918
|
-
|
|
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);
|
|
1919
2608
|
}
|
|
1920
2609
|
/** Run a route that has already been matched by the runtime or Nifra's portable router. */
|
|
1921
2610
|
runMatched(source, platform, entry, params, search, finalize, wrapResponse, onTimeout, webFast) {
|
|
@@ -1928,8 +2617,10 @@ export class Server {
|
|
|
1928
2617
|
// handler can reach `c.req`; that lane already enforces `entry.bodyLimit`. Installing the full
|
|
1929
2618
|
// direct-reader cap on `c.req` here would allocate bound readers, closures, and a stream wrapper
|
|
1930
2619
|
// for a body that is already consumed. Keep the lazy transport cap for raw-body routes, where a
|
|
1931
|
-
// user read is the only framework-owned body boundary
|
|
1932
|
-
|
|
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)) {
|
|
1933
2624
|
const method = source.method;
|
|
1934
2625
|
if (method !== "GET" && method !== "HEAD")
|
|
1935
2626
|
markTransportCap(source, entry.bodyLimit);
|
|
@@ -2176,7 +2867,7 @@ export class Server {
|
|
|
2176
2867
|
? RequestContext.native(source, params, search, maxBodyBytes, platform, this.protoPoisoning)
|
|
2177
2868
|
: new RequestContext(source, params, search, signal, budget, platform, maxBodyBytes, this.protoPoisoning);
|
|
2178
2869
|
const finish = (value) => runParsed(value, ctx, finalize, wrapResponse);
|
|
2179
|
-
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);
|
|
2180
2871
|
};
|
|
2181
2872
|
}
|
|
2182
2873
|
/** Web adapter wrapper for the shared body runner. Node passes its native finalizer directly through
|
|
@@ -2548,14 +3239,294 @@ export class Server {
|
|
|
2548
3239
|
ctx.body = validation.value;
|
|
2549
3240
|
return runHandlerValidated();
|
|
2550
3241
|
};
|
|
2551
|
-
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);
|
|
2552
3523
|
};
|
|
2553
3524
|
}
|
|
2554
3525
|
bareError(err, ctx, finalize, wrapResponse) {
|
|
2555
3526
|
// The thrown-value contract lives in `bare-error-lane.ts`, one source of truth for both this
|
|
2556
3527
|
// lane and the fused body runner. `responseSet` and the logger are injected because they reach
|
|
2557
3528
|
// into class state; the closure is allocated only on the throw path, never on the hot lane.
|
|
2558
|
-
return renderBareError(err, ctx, finalize,
|
|
3529
|
+
return renderBareError(err, ctx, finalize, responseSet, (e, c) => this.logRequestError(e, c), () => this.internalErrorResponse(wrapResponse));
|
|
2559
3530
|
}
|
|
2560
3531
|
/**
|
|
2561
3532
|
* Keep the ordinary thrown-error response independent from the caught value. Deliberate Response
|
|
@@ -2585,15 +3556,13 @@ export class Server {
|
|
|
2585
3556
|
/** Bind a compiled validation stage to its stable schema and the existing recovery contract. */
|
|
2586
3557
|
// @ts-expect-error TS6133 -- invoked structurally by the general route program
|
|
2587
3558
|
validateProgramStage(entry, stage, source, ctx) {
|
|
2588
|
-
const input = stage.kind === "headers"
|
|
2589
|
-
? headerObjectOf(source.headers)
|
|
2590
|
-
: stage.kind === "params"
|
|
2591
|
-
? ctx.params
|
|
2592
|
-
: stage.kind === "query"
|
|
2593
|
-
? queryObjectOf(ctx[CONTEXT_SEARCH])
|
|
2594
|
-
: undefined;
|
|
2595
3559
|
if (stage.kind === "body")
|
|
2596
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];
|
|
2597
3566
|
const validation = stage.schema["~standard"].validate(input);
|
|
2598
3567
|
return validation instanceof Promise
|
|
2599
3568
|
? validation.then((result) => this.applyLifecycleValidation(entry, result, ctx, stage.kind))
|
|
@@ -2633,7 +3602,7 @@ export class Server {
|
|
|
2633
3602
|
}
|
|
2634
3603
|
// @ts-expect-error TS6133 -- invoked structurally by compiled plans (internal/route-execution.ts)
|
|
2635
3604
|
runBodyOnly(entry, source, ctx, finalize, wrapResponse) {
|
|
2636
|
-
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);
|
|
2637
3606
|
}
|
|
2638
3607
|
/** Validate + run the handler for the bodyOnly path - shared by the inline fast path and the
|
|
2639
3608
|
* streaming fallback. A method (not per-request closures) so the hot path allocates nothing
|
|
@@ -2881,7 +3850,7 @@ export class Server {
|
|
|
2881
3850
|
const contract = entry.responseContract;
|
|
2882
3851
|
if (contract === undefined)
|
|
2883
3852
|
return finalize(result, responseSet(ctx));
|
|
2884
|
-
const checked = contract.runtime.check(contract.
|
|
3853
|
+
const checked = contract.runtime.check(contract.definition, result, ctx);
|
|
2885
3854
|
if (checked instanceof Promise) {
|
|
2886
3855
|
return checked.then((outcome) => this.finishContractOutcome(ctx, finalize, wrapResponse, outcome), (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse));
|
|
2887
3856
|
}
|
|
@@ -2916,15 +3885,16 @@ export class Server {
|
|
|
2916
3885
|
* loop, which may await, drops into {@link runErrorHooks}. */
|
|
2917
3886
|
handleLifecycleError(entry, err, ctx, finalize, wrapResponse) {
|
|
2918
3887
|
// A *thrown* Response is deliberate control flow, not an error - a guard throws a redirect/401,
|
|
2919
|
-
// an action throws an error page.
|
|
2920
|
-
//
|
|
2921
|
-
//
|
|
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.
|
|
2922
3892
|
if (err instanceof Response)
|
|
2923
|
-
return
|
|
3893
|
+
return finalize(err, responseSet(ctx));
|
|
2924
3894
|
// Same rule for a thrown `status(...)`, but through `finalize` rather than `wrapResponse`: the
|
|
2925
3895
|
// value is still plain data, so the ordinary JSON lane renders it and no `Response` is built.
|
|
2926
3896
|
if (isResponseResult(err))
|
|
2927
|
-
return
|
|
3897
|
+
return this.finishLifecycleContract(entry, ctx, finalize, wrapResponse, err);
|
|
2928
3898
|
if (entry.onError.length === 0) {
|
|
2929
3899
|
// Never crash the server or leak internals. The client gets a flat 500; the detail goes to the
|
|
2930
3900
|
// (redacting) logger. Body-read failures and around-hook failures land here too.
|
|
@@ -2963,20 +3933,14 @@ export class Server {
|
|
|
2963
3933
|
? validation.then((result) => this.applyLifecycleValidation(entry, result, ctx, "body"))
|
|
2964
3934
|
: this.applyLifecycleValidation(entry, validation, ctx, "body");
|
|
2965
3935
|
};
|
|
2966
|
-
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);
|
|
2967
3937
|
}
|
|
2968
3938
|
/** Apply validation and its recovery hook on the generic lifecycle lane. Recovery is completed
|
|
2969
3939
|
* before derives/beforeHandle run, matching the body-only and query-only execution lanes. */
|
|
2970
3940
|
applyLifecycleValidation(entry, result, ctx, kind) {
|
|
2971
3941
|
const assign = (value) => {
|
|
2972
|
-
|
|
2973
|
-
|
|
2974
|
-
else if (kind === "query")
|
|
2975
|
-
ctx.query = value;
|
|
2976
|
-
else if (kind === "headers")
|
|
2977
|
-
ctx.headers = value;
|
|
2978
|
-
else
|
|
2979
|
-
ctx.params = value;
|
|
3942
|
+
;
|
|
3943
|
+
ctx[kind] = value;
|
|
2980
3944
|
};
|
|
2981
3945
|
if (result.issues === undefined) {
|
|
2982
3946
|
assign(result.value);
|
|
@@ -2996,14 +3960,7 @@ export class Server {
|
|
|
2996
3960
|
return plainValidationError(issues);
|
|
2997
3961
|
if (recovery instanceof Response)
|
|
2998
3962
|
return recovery;
|
|
2999
|
-
const
|
|
3000
|
-
? entry.schema?.body
|
|
3001
|
-
: kind === "query"
|
|
3002
|
-
? entry.schema?.query
|
|
3003
|
-
: kind === "params"
|
|
3004
|
-
? entry.schema?.params
|
|
3005
|
-
: entry.schema?.headers;
|
|
3006
|
-
const retried = schema["~standard"].validate(recovery);
|
|
3963
|
+
const retried = entry.schema[kind]["~standard"].validate(recovery);
|
|
3007
3964
|
if (retried instanceof Promise) {
|
|
3008
3965
|
return retried.then((settled) => {
|
|
3009
3966
|
if (settled.issues !== undefined)
|
|
@@ -3019,7 +3976,7 @@ export class Server {
|
|
|
3019
3976
|
}
|
|
3020
3977
|
/** Adapt one route's compiled execution plan to Bun's already-matched request shape. Route
|
|
3021
3978
|
* semantics remain in the plan; this closure only supplies native params and deadline fallback. */
|
|
3022
|
-
compileBunNativeHandler(entry, paramNames, fused, signal, budget) {
|
|
3979
|
+
compileBunNativeHandler(entry, paramNames, fused, signal, budget, peer, fallback) {
|
|
3023
3980
|
// This callback is reached only from Bun's compiled native route table. Bun has already parsed
|
|
3024
3981
|
// the HTTP framing, so the JSON lane may retain its native fused `json()` parse without weakening
|
|
3025
3982
|
// the portable/adapted source contract (those sources are never marked).
|
|
@@ -3027,7 +3984,9 @@ export class Server {
|
|
|
3027
3984
|
// The fused native lane bypasses `runMatched`, so the route's transport byte cap must be
|
|
3028
3985
|
// marked here too - otherwise a Bun `listen()` fused route would leave direct `c.req` body
|
|
3029
3986
|
// reads uncapped. The non-fused branches go through `fetchMatched` -> `runMatched`, which marks.
|
|
3030
|
-
const bodyLimit = entry.schema?.body === undefined
|
|
3987
|
+
const bodyLimit = entry.schema?.body === undefined || entry.program.authBeforeValidation
|
|
3988
|
+
? entry.bodyLimit
|
|
3989
|
+
: undefined;
|
|
3031
3990
|
const inner = fused;
|
|
3032
3991
|
const capped = inner === undefined || bodyLimit === undefined
|
|
3033
3992
|
? inner
|
|
@@ -3037,73 +3996,90 @@ export class Server {
|
|
|
3037
3996
|
markTransportCap(source, bodyLimit);
|
|
3038
3997
|
return inner(source, params, search, fusedSignal, fusedBudget, platform, nativeContext);
|
|
3039
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
|
+
};
|
|
3040
4011
|
if (paramNames.length === 0) {
|
|
3041
4012
|
if (capped === undefined) {
|
|
3042
4013
|
return (request) => {
|
|
3043
4014
|
markFramed(request);
|
|
3044
|
-
return this.fetchMatched(request, entry, EMPTY_PARAMS);
|
|
4015
|
+
return this.fetchMatched(request, entry, EMPTY_PARAMS, peer);
|
|
3045
4016
|
};
|
|
3046
4017
|
}
|
|
3047
4018
|
if (this.acceptInboundDeadlines) {
|
|
3048
4019
|
return (request) => {
|
|
3049
4020
|
markFramed(request);
|
|
3050
4021
|
return request.headers.get(NIFRA_DEADLINE_HEADER) !== null
|
|
3051
|
-
? this.fetchMatched(request, entry, EMPTY_PARAMS)
|
|
3052
|
-
: 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);
|
|
3053
4024
|
};
|
|
3054
4025
|
}
|
|
3055
4026
|
return (request) => {
|
|
3056
4027
|
markFramed(request);
|
|
3057
|
-
return
|
|
4028
|
+
return runNative(request, EMPTY_PARAMS);
|
|
3058
4029
|
};
|
|
3059
4030
|
}
|
|
3060
4031
|
const malformed = paramNames.length === 1
|
|
3061
|
-
? (params) => params[paramNames[0]]
|
|
3062
|
-
:
|
|
4032
|
+
? (params) => unroutedParam(params[paramNames[0]])
|
|
4033
|
+
: hasUnroutedParam;
|
|
3063
4034
|
if (capped === undefined) {
|
|
3064
|
-
return (request) => {
|
|
4035
|
+
return (request, server) => {
|
|
3065
4036
|
markFramed(request);
|
|
3066
4037
|
const params = request.params ?? EMPTY_PARAMS;
|
|
3067
4038
|
if (malformed(params))
|
|
3068
|
-
return
|
|
3069
|
-
return this.fetchMatched(request, entry, params);
|
|
4039
|
+
return fallback(request, server);
|
|
4040
|
+
return this.fetchMatched(request, entry, params, peer);
|
|
3070
4041
|
};
|
|
3071
4042
|
}
|
|
3072
4043
|
if (this.acceptInboundDeadlines) {
|
|
3073
|
-
return (request) => {
|
|
4044
|
+
return (request, server) => {
|
|
3074
4045
|
markFramed(request);
|
|
3075
4046
|
const params = request.params ?? EMPTY_PARAMS;
|
|
3076
4047
|
if (malformed(params))
|
|
3077
|
-
return
|
|
4048
|
+
return fallback(request, server);
|
|
3078
4049
|
return request.headers.get(NIFRA_DEADLINE_HEADER) !== null
|
|
3079
|
-
? this.fetchMatched(request, entry, params)
|
|
3080
|
-
: capped(request, params, undefined, signal, budget,
|
|
4050
|
+
? this.fetchMatched(request, entry, params, peer)
|
|
4051
|
+
: capped(request, params, undefined, signal, budget, peer, true);
|
|
3081
4052
|
};
|
|
3082
4053
|
}
|
|
3083
|
-
return (request) => {
|
|
4054
|
+
return (request, server) => {
|
|
3084
4055
|
markFramed(request);
|
|
3085
4056
|
const params = request.params ?? EMPTY_PARAMS;
|
|
3086
4057
|
if (malformed(params))
|
|
3087
|
-
return
|
|
3088
|
-
return
|
|
4058
|
+
return fallback(request, server);
|
|
4059
|
+
return runNative(request, params);
|
|
3089
4060
|
};
|
|
3090
4061
|
}
|
|
3091
4062
|
/** Compile portable route registrations into Bun's native route table. Apps with request-rewrite
|
|
3092
4063
|
* hooks or WebSockets retain the single portable dispatcher because those features must run before
|
|
3093
|
-
* route selection/upgrade.
|
|
3094
|
-
*
|
|
3095
|
-
|
|
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) {
|
|
3096
4071
|
// A `clientIp` trust declaration must run the resolver in `dispatch`, which the fused native lane
|
|
3097
4072
|
// bypasses - so an app that declares trust routes through the fetch lane (where `c.clientIp`
|
|
3098
4073
|
// resolves) instead of Bun's native table. The allocation-free default keeps native fusion.
|
|
3099
|
-
if (this.onRequestHooks.length > 0 ||
|
|
4074
|
+
if ((this.onRequestHooks.length > 0 && !this.bunNativeRequestHooksSafe) ||
|
|
3100
4075
|
this.wsRouteCount > 0 ||
|
|
4076
|
+
this.preRouteMountCount > 0 ||
|
|
3101
4077
|
this.clientIpTrust !== undefined) {
|
|
3102
4078
|
return undefined;
|
|
3103
4079
|
}
|
|
3104
4080
|
const routes = Object.create(null);
|
|
3105
4081
|
const mayUseFusedNative = this.requestTimeoutMs === 0 &&
|
|
3106
|
-
this.onResponseHooks.length === 0 &&
|
|
4082
|
+
(this.onResponseHooks.length === 0 || this.bunNativeResponseHeadersOnly) &&
|
|
3107
4083
|
this.onResponseFinalizedHooks.length === 0 &&
|
|
3108
4084
|
// The capacity gate must wrap every request; the fused lane bypasses fetchMatched, so enabling
|
|
3109
4085
|
// admission drops fusion (native matching stays) and routes through the gated matched lane.
|
|
@@ -3111,18 +4087,65 @@ export class Server {
|
|
|
3111
4087
|
const unboundedSignal = mayUseFusedNative ? getNeverAbortSignal() : undefined;
|
|
3112
4088
|
const unboundedBudget = mayUseFusedNative ? getUnboundedRequestBudget() : undefined;
|
|
3113
4089
|
let count = 0;
|
|
3114
|
-
|
|
3115
|
-
|
|
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))) {
|
|
3116
4138
|
continue;
|
|
3117
|
-
let methods = routes[path];
|
|
3118
|
-
if (methods === undefined) {
|
|
3119
|
-
methods = Object.create(null);
|
|
3120
|
-
routes[path] = methods;
|
|
3121
4139
|
}
|
|
3122
4140
|
const paramNames = pattern.paramNames;
|
|
3123
4141
|
const fused = mayUseFusedNative ? entry.execution.fusedWeb : undefined;
|
|
3124
|
-
|
|
4142
|
+
tableAt(path)[method] = this.compileBunNativeHandler(entry, paramNames, fused, unboundedSignal, unboundedBudget, peer, fallback);
|
|
3125
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
|
+
}
|
|
3126
4149
|
}
|
|
3127
4150
|
if (count === 0)
|
|
3128
4151
|
return undefined;
|
|
@@ -3135,6 +4158,19 @@ export class Server {
|
|
|
3135
4158
|
if (methods.GET !== undefined)
|
|
3136
4159
|
methods.HEAD ??= methods.GET;
|
|
3137
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
|
+
}
|
|
3138
4174
|
return routes;
|
|
3139
4175
|
}
|
|
3140
4176
|
/**
|
|
@@ -3158,6 +4194,10 @@ export class Server {
|
|
|
3158
4194
|
* connection cut mid-flight regardless of `requestTimeoutMs`. Apps with such endpoints must raise
|
|
3159
4195
|
* this above their slowest expected response, which is why it is a first-class option and not a
|
|
3160
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.
|
|
3161
4201
|
*/
|
|
3162
4202
|
listen(port, options) {
|
|
3163
4203
|
if (typeof Bun === "undefined") {
|
|
@@ -3182,31 +4222,57 @@ export class Server {
|
|
|
3182
4222
|
// because wsRouteCount > 0 means ws() ran, and ws() requires the runtime at registration.
|
|
3183
4223
|
// Native pub/sub when the app has WS routes and none validate outbound frames: `ws.subscribe` and
|
|
3184
4224
|
// `app.publish` go through Bun's own (uWebSockets) broadcast instead of the JS registry loop.
|
|
3185
|
-
|
|
3186
|
-
|
|
3187
|
-
|
|
3188
|
-
|
|
3189
|
-
|
|
3190
|
-
|
|
3191
|
-
//
|
|
3192
|
-
|
|
3193
|
-
//
|
|
3194
|
-
|
|
3195
|
-
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;
|
|
3196
4268
|
const running = (wsHandlers === undefined
|
|
3197
4269
|
? Bun.serve({
|
|
3198
|
-
|
|
3199
|
-
reusePort,
|
|
3200
|
-
...bind,
|
|
3201
|
-
...idle,
|
|
4270
|
+
...listening,
|
|
3202
4271
|
...(nativeRoutes === undefined ? {} : { routes: nativeRoutes }),
|
|
3203
|
-
fetch:
|
|
4272
|
+
fetch: fallback,
|
|
3204
4273
|
})
|
|
3205
4274
|
: Bun.serve({
|
|
3206
|
-
|
|
3207
|
-
reusePort,
|
|
3208
|
-
...bind,
|
|
3209
|
-
...idle,
|
|
4275
|
+
...listening,
|
|
3210
4276
|
fetch: (req, server) => this.bunFetchWithWebSocket(req, server),
|
|
3211
4277
|
// Bun's `ServerWebSocket<BunWsData>` is runtime-compatible with the handlers' structural
|
|
3212
4278
|
// `BunSocket` view (kept local so `Bun.*` types never leak into the published .d.ts); the
|
|
@@ -3222,7 +4288,9 @@ export class Server {
|
|
|
3222
4288
|
}));
|
|
3223
4289
|
this.bunServer = running;
|
|
3224
4290
|
// Bind `app.publish` to Bun's native broadcast now that the server handle exists. Guarded on the
|
|
3225
|
-
// 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;
|
|
3226
4294
|
if (nativePubsub && typeof running.publish === "function") {
|
|
3227
4295
|
const native = running.publish.bind(running);
|
|
3228
4296
|
this.nativePublish = (topic, data) => {
|