@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/src/server/server.ts
CHANGED
|
@@ -31,23 +31,25 @@ import {
|
|
|
31
31
|
type InternalHandler,
|
|
32
32
|
type RawAfterHandle,
|
|
33
33
|
type RawAround,
|
|
34
|
+
type RawAuthStage,
|
|
34
35
|
type RawBeforeHandle,
|
|
35
36
|
type RawDerive,
|
|
36
37
|
type RawErrorHandler,
|
|
37
38
|
type RouteEntry,
|
|
38
39
|
} from "../internal/route-execution.ts"
|
|
39
|
-
import type { RouteProgramStage } from "../internal/route-program.ts"
|
|
40
|
+
import type { ProgramValidationKind, RouteProgramStage } from "../internal/route-program.ts"
|
|
40
41
|
import { compileRouteProgram, executeRouteProgram } from "../internal/route-program.ts"
|
|
41
42
|
import { isSameOriginRequest } from "../internal/same-origin.ts"
|
|
42
43
|
import type { ResponseObserverPlugin } from "../response-observer.ts"
|
|
43
|
-
import { decodeRouteParams } from "../router/pattern.ts"
|
|
44
|
-
import { EMPTY_PARAMS, type Method, Router } from "../router/router.ts"
|
|
44
|
+
import { decodeRouteParams, expandOptionalParams } from "../router/pattern.ts"
|
|
45
|
+
import { EMPTY_PARAMS, METHODS, type Method, Router } from "../router/router.ts"
|
|
45
46
|
import type {
|
|
46
47
|
InferOutput,
|
|
47
48
|
StandardIssue,
|
|
48
49
|
StandardResult,
|
|
49
50
|
StandardSchemaV1,
|
|
50
51
|
} from "../schema/standard.ts"
|
|
52
|
+
import type { AuthenticationStage } from "./auth.ts"
|
|
51
53
|
import { emitRequestErrorLog, renderBareError } from "./bare-error-lane.ts"
|
|
52
54
|
import {
|
|
53
55
|
assertByteLimit,
|
|
@@ -56,20 +58,33 @@ import {
|
|
|
56
58
|
type RawBodyReaders,
|
|
57
59
|
UNLIMITED_BODY_BYTES,
|
|
58
60
|
} from "./body.ts"
|
|
59
|
-
import {
|
|
61
|
+
import {
|
|
62
|
+
type ClientIpTrust,
|
|
63
|
+
NIFRA_PLATFORM_CLIENT_IP_DERIVED,
|
|
64
|
+
resolveClientIp,
|
|
65
|
+
} from "./client-ip.ts"
|
|
60
66
|
import type { Context, Platform, ResponseControls, RouteSchema } from "./context.ts"
|
|
67
|
+
import "./core-copies.ts"
|
|
61
68
|
import {
|
|
62
69
|
hasLowercaseHeaderKeysMark,
|
|
63
70
|
headerKeysAllLowercase,
|
|
64
71
|
markLowercaseHeaderKeys,
|
|
65
72
|
} from "./header-case.ts"
|
|
66
73
|
import { headerObjectOf } from "./headers.ts"
|
|
67
|
-
import {
|
|
74
|
+
import {
|
|
75
|
+
isRoutableMethod,
|
|
76
|
+
jsonError,
|
|
77
|
+
pathnameOf,
|
|
78
|
+
plainError,
|
|
79
|
+
type UrlParts,
|
|
80
|
+
urlPartsOf,
|
|
81
|
+
} from "./http.ts"
|
|
68
82
|
import { type NodeServeOutcome, withStaticNodeHeaders } from "./node-outcome.ts"
|
|
69
83
|
import type {
|
|
70
84
|
NodeOutcomeRuntime,
|
|
71
85
|
NodeRequestContext,
|
|
72
86
|
NodeRequestHook,
|
|
87
|
+
NodeResponseBodyHook,
|
|
73
88
|
NodeResponseContext,
|
|
74
89
|
NodeResponseHook,
|
|
75
90
|
ResponseBodyHook,
|
|
@@ -78,7 +93,14 @@ import type {
|
|
|
78
93
|
ResponseHeadersView,
|
|
79
94
|
} from "./node-outcome-hook.ts"
|
|
80
95
|
import { type QueryValue, queryObjectOf, searchOf } from "./query.ts"
|
|
81
|
-
import {
|
|
96
|
+
import {
|
|
97
|
+
type PeerPlatform,
|
|
98
|
+
PLATFORM_PEER,
|
|
99
|
+
RequestContext,
|
|
100
|
+
readBodyFramed,
|
|
101
|
+
} from "./request-context.ts"
|
|
102
|
+
import { recordRequestReplacement } from "./request-lineage.ts"
|
|
103
|
+
import { hasDotSegment, resolveDotSegments } from "./request-target.ts"
|
|
82
104
|
import {
|
|
83
105
|
applyStaticResponseHeaders,
|
|
84
106
|
buildStaticResponseHeaders,
|
|
@@ -114,6 +136,7 @@ import { plainValidationError } from "./validation.ts"
|
|
|
114
136
|
export type {
|
|
115
137
|
NodeRequestContext,
|
|
116
138
|
NodeRequestHook,
|
|
139
|
+
NodeResponseBodyHook,
|
|
117
140
|
NodeResponseContext,
|
|
118
141
|
NodeResponseHook,
|
|
119
142
|
NodeServeOutcome,
|
|
@@ -123,12 +146,15 @@ export type {
|
|
|
123
146
|
ResponseHeadersView,
|
|
124
147
|
}
|
|
125
148
|
|
|
149
|
+
import { NIFRA_BACKEND_WS_RUNTIME } from "../mount.ts"
|
|
126
150
|
import type { IdempotencyRuntime } from "./idempotency-lane.ts"
|
|
127
151
|
import {
|
|
152
|
+
GET_WS_RUNTIME,
|
|
128
153
|
INSTALL_EFFECT_LEDGER,
|
|
129
154
|
INSTALL_IDEMPOTENCY,
|
|
130
155
|
INSTALL_MCP,
|
|
131
156
|
INSTALL_NODE_DIRECT,
|
|
157
|
+
INSTALL_NOT_FOUND,
|
|
132
158
|
INSTALL_RESPONSE_CONTRACT,
|
|
133
159
|
INSTALL_RESPONSE_OBSERVER,
|
|
134
160
|
INSTALL_SSE,
|
|
@@ -139,16 +165,20 @@ import {
|
|
|
139
165
|
import type { EffectLedgerRuntime } from "./ledger-lane.ts"
|
|
140
166
|
import { jsonLogger, type Logger } from "./logger.ts"
|
|
141
167
|
import type { McpRuntime } from "./mcp-hook.ts"
|
|
168
|
+
import type { NotFoundLane } from "./not-found-answer.ts"
|
|
142
169
|
import type {
|
|
143
170
|
ContextPlugin,
|
|
144
171
|
IdentityPlugin,
|
|
172
|
+
MethodRoutesPlugin,
|
|
145
173
|
PluginTypeCollapsed,
|
|
146
174
|
ServerTypeUnpinned,
|
|
175
|
+
WithMethodRoutes,
|
|
147
176
|
} from "./plugin.ts"
|
|
148
177
|
import type {
|
|
149
178
|
AddRoute,
|
|
150
179
|
EmptyRegistry,
|
|
151
180
|
OutputOf,
|
|
181
|
+
PrefixRegistry,
|
|
152
182
|
Registry,
|
|
153
183
|
RouteInfoFor,
|
|
154
184
|
WsRouteInfoFor,
|
|
@@ -157,10 +187,13 @@ import type {
|
|
|
157
187
|
AdmissionController,
|
|
158
188
|
AdmissionDecision,
|
|
159
189
|
FetchHandler,
|
|
190
|
+
ListenTlsOptions,
|
|
160
191
|
McpPromptDescriptor,
|
|
161
192
|
McpResourceDescriptor,
|
|
162
193
|
Middleware,
|
|
194
|
+
MountableApp,
|
|
163
195
|
MountFetchOptions,
|
|
196
|
+
MountOptions,
|
|
164
197
|
PromptArgument,
|
|
165
198
|
PromptMessage,
|
|
166
199
|
ResponseFinalization,
|
|
@@ -183,6 +216,11 @@ import type { WsRuntime } from "./ws-hook.ts"
|
|
|
183
216
|
|
|
184
217
|
export type MaybePromise<T> = T | Promise<T>
|
|
185
218
|
|
|
219
|
+
// CORS uses this marker to say its request hook only handles OPTIONS preflight. Bun can keep
|
|
220
|
+
// ordinary registered routes on its native table and send unmatched OPTIONS requests through the
|
|
221
|
+
// normal fetch fallback where the hook still runs.
|
|
222
|
+
const BUN_NATIVE_REQUEST_SAFE = Symbol.for("@nifrajs/core/bun-native-request-safe")
|
|
223
|
+
|
|
186
224
|
/**
|
|
187
225
|
* Internal request view. A real Web `Request` already satisfies this shape, so Web/edge runtimes pass
|
|
188
226
|
* their `Request` **directly** (zero wrapper allocation on the hot path - `request` is simply absent and
|
|
@@ -234,15 +272,18 @@ export interface RawContext {
|
|
|
234
272
|
readonly request: Request
|
|
235
273
|
readonly json: (body: unknown, init?: ResponseInit | number) => Response
|
|
236
274
|
readonly text: (body: string, init?: ResponseInit | number) => Response
|
|
237
|
-
// Writable: the lifecycle replaces
|
|
238
|
-
// declared (handlers still see
|
|
275
|
+
// Writable: the lifecycle replaces each with the validated/coerced value when its schema is
|
|
276
|
+
// declared (handlers still see them `readonly` via the public `Context` interface).
|
|
239
277
|
params: Record<string, string>
|
|
240
278
|
headers: Record<string, string>
|
|
241
279
|
query: unknown
|
|
242
|
-
|
|
280
|
+
cookies: Readonly<Record<string, string>>
|
|
243
281
|
body: unknown
|
|
244
282
|
readonly set: ResponseControls
|
|
245
283
|
readonly [CONTEXT_SET]: () => CtxSet | undefined
|
|
284
|
+
/** The last `json()` reply and the value it was built from, for the response-contract lane. */
|
|
285
|
+
readonly jsonReply?: Response | undefined
|
|
286
|
+
readonly jsonBody?: unknown
|
|
246
287
|
readonly [CONTEXT_SEARCH]: string
|
|
247
288
|
readonly signal: AbortSignal
|
|
248
289
|
readonly budget: RequestBudget
|
|
@@ -266,13 +307,21 @@ interface WsEntry {
|
|
|
266
307
|
readonly handler: WebSocketHandler
|
|
267
308
|
}
|
|
268
309
|
|
|
310
|
+
/** Structural view of the socket-peer lookup on the Bun `Server`, so any Bun `Server` (WS or not)
|
|
311
|
+
* satisfies it. */
|
|
312
|
+
interface BunPeerServer {
|
|
313
|
+
requestIP(request: Request): { readonly address: string } | null
|
|
314
|
+
}
|
|
315
|
+
|
|
269
316
|
/** Structural view of the Bun `Server` the `fetch` 2nd arg exposes (`upgrade` + the socket peer). */
|
|
270
|
-
interface BunUpgradeServer {
|
|
317
|
+
interface BunUpgradeServer extends BunPeerServer {
|
|
271
318
|
upgrade(request: Request, options?: { data?: BunWsData }): boolean
|
|
272
|
-
requestIP(request: Request): { readonly address: string } | null
|
|
273
319
|
}
|
|
274
320
|
|
|
275
|
-
type MountedFetchHandler =
|
|
321
|
+
type MountedFetchHandler<Env = unknown> = (
|
|
322
|
+
request: Request,
|
|
323
|
+
platform?: Platform<Env>,
|
|
324
|
+
) => MaybePromise<Response>
|
|
276
325
|
|
|
277
326
|
/** Structural native mount contract. The serving adapter owns the concrete Node request/response
|
|
278
327
|
* types; the kernel only selects a handler after proving that taking this lane cannot skip its
|
|
@@ -290,21 +339,31 @@ interface NativeMountSelection {
|
|
|
290
339
|
readonly stripPrefix: boolean
|
|
291
340
|
}
|
|
292
341
|
|
|
293
|
-
interface FetchMount {
|
|
342
|
+
interface FetchMount<Env = unknown> {
|
|
294
343
|
readonly path: string
|
|
295
|
-
readonly handler: MountedFetchHandler
|
|
344
|
+
readonly handler: MountedFetchHandler<Env>
|
|
345
|
+
readonly resolveWebSocketUpgrade?: (
|
|
346
|
+
request: Request,
|
|
347
|
+
platform?: Platform<Env>,
|
|
348
|
+
) => MaybePromise<WebSocketUpgradeOutcome>
|
|
349
|
+
/** Set with the resolver: the runtime the child's WebSocket upgrades need (see `[GET_WS_RUNTIME]`). */
|
|
350
|
+
readonly wsRuntime?: (seen: Set<unknown>) => WsRuntime | null | undefined
|
|
296
351
|
readonly stripPrefix: boolean
|
|
352
|
+
readonly priority: number
|
|
353
|
+
readonly fallbackOn404: boolean
|
|
354
|
+
readonly beforeRoutes: boolean
|
|
355
|
+
readonly order: number
|
|
356
|
+
/** The declared reason this child is not analyzed (`MountOptions.opaque`), checked by reflection. */
|
|
357
|
+
readonly opaque: string | undefined
|
|
358
|
+
/** The mounted app (`mount()` only), for reflection to tell a composed child from an opaque one. */
|
|
359
|
+
readonly app?: object
|
|
297
360
|
}
|
|
298
361
|
|
|
299
|
-
/** The socket peer Bun observed, as a `Platform` for the request lifecycle (`undefined` if unknown).
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
server: { requestIP(request: Request): { readonly address: string } | null },
|
|
303
|
-
req: Request,
|
|
304
|
-
): Platform {
|
|
305
|
-
// Bun's requestIP() is surprisingly expensive (~20 us on the SSR benchmark machine). Keep the
|
|
362
|
+
/** The socket peer Bun observed, as a `Platform` for the request lifecycle (`undefined` if unknown). */
|
|
363
|
+
function bunPeerPlatform(server: BunPeerServer, req: Request): Platform {
|
|
364
|
+
// Bun's requestIP() costs ~0.5 us per call, about 7% of a bare GET's server time. Keep the
|
|
306
365
|
// documented raw-peer c.clientIp behavior, but resolve it lazily: most routes never read c.clientIp,
|
|
307
|
-
//
|
|
366
|
+
// so they should not pay for the socket lookup on every request. A getter also
|
|
308
367
|
// preserves middleware that inspects the platform argument directly and trust-mode routes, which
|
|
309
368
|
// resolve the value in deriveClientIp before the handler runs.
|
|
310
369
|
let resolved = false
|
|
@@ -320,12 +379,42 @@ function bunPeerPlatform(
|
|
|
320
379
|
}
|
|
321
380
|
}
|
|
322
381
|
|
|
323
|
-
type BunNativeHandler = (request: Request) => MaybePromise<Response>
|
|
382
|
+
type BunNativeHandler = (request: Request, server: BunPeerServer) => MaybePromise<Response>
|
|
324
383
|
type BunNativeMethodTable = Partial<Record<Method, BunNativeHandler>>
|
|
325
384
|
type BunNativeRoutes = Record<string, BunNativeMethodTable>
|
|
326
385
|
type BunRequestWithParams = Request & { readonly params?: Record<string, string> }
|
|
327
386
|
|
|
387
|
+
/**
|
|
388
|
+
* Whether the portable router gives `other` a request that `route` also matches: the two patterns can
|
|
389
|
+
* match one path, and `other` is the more specific at the first segment where they differ. `route`
|
|
390
|
+
* holds static and `:param` segments only; `other` has a wildcard or a part-literal segment.
|
|
391
|
+
*
|
|
392
|
+
* It errs toward `true`. A segment that is part literal, part parameter is taken to match whatever
|
|
393
|
+
* `route` has in that position.
|
|
394
|
+
*/
|
|
395
|
+
function outranks(
|
|
396
|
+
other: CatalogRoute["pattern"]["segments"],
|
|
397
|
+
route: CatalogRoute["pattern"]["segments"],
|
|
398
|
+
): boolean {
|
|
399
|
+
let ahead = false
|
|
400
|
+
for (let i = 0; i < route.length; i++) {
|
|
401
|
+
const theirs = other[i]
|
|
402
|
+
if (theirs === undefined) return false
|
|
403
|
+
if (theirs.kind === "wildcard") return ahead
|
|
404
|
+
const ours = route[i]!
|
|
405
|
+
if (ours.kind === "static") {
|
|
406
|
+
if (theirs.kind === "static" ? theirs.value !== ours.value : !ahead) return false
|
|
407
|
+
} else if (theirs.kind !== "param") ahead = true
|
|
408
|
+
}
|
|
409
|
+
return ahead && other.length === route.length
|
|
410
|
+
}
|
|
411
|
+
|
|
328
412
|
const WS_PASS: WebSocketUpgradeOutcome = { kind: "pass" }
|
|
413
|
+
const DEFAULT_WS_UPGRADE_TIMEOUT_MS = 10_000
|
|
414
|
+
/** Counts `ws()` routes and `mount()`s across every app in this copy of core: the only
|
|
415
|
+
* changes that can make a mounted app take WebSocket upgrades. An app's answer to that question
|
|
416
|
+
* (`mountsTakeUpgrades`) is kept until this moves. */
|
|
417
|
+
let wsChanges = 0
|
|
329
418
|
|
|
330
419
|
/** `app.ws()` (and everything downstream of it) needs the runtime `@nifrajs/core/ws` registers. */
|
|
331
420
|
function requireWsRuntime(runtime: WsRuntime | undefined): WsRuntime {
|
|
@@ -407,10 +496,13 @@ export type {
|
|
|
407
496
|
AdmissionController,
|
|
408
497
|
AdmissionDecision,
|
|
409
498
|
FetchHandler,
|
|
499
|
+
ListenTlsOptions,
|
|
410
500
|
McpPromptDescriptor,
|
|
411
501
|
McpResourceDescriptor,
|
|
412
502
|
Middleware,
|
|
503
|
+
MountableApp,
|
|
413
504
|
MountFetchOptions,
|
|
505
|
+
MountOptions,
|
|
414
506
|
PromptArgument,
|
|
415
507
|
PromptMessage,
|
|
416
508
|
ResponseFinalization,
|
|
@@ -435,8 +527,10 @@ export {
|
|
|
435
527
|
defineIdentityPlugin,
|
|
436
528
|
definePlugin,
|
|
437
529
|
defineRouterPlugin,
|
|
530
|
+
type MethodRoutesPlugin,
|
|
438
531
|
type NifraPlugin,
|
|
439
532
|
type PluginTypeCollapsed,
|
|
533
|
+
type WithMethodRoutes,
|
|
440
534
|
} from "./plugin.ts"
|
|
441
535
|
export type { IdentityPlugin }
|
|
442
536
|
|
|
@@ -461,13 +555,31 @@ export { plainValidationError } from "./validation.ts"
|
|
|
461
555
|
// importers keep resolving `searchOf`/`queryObjectOf`/`QueryValue` from here.
|
|
462
556
|
export { type QueryValue, queryObjectOf, searchOf }
|
|
463
557
|
|
|
464
|
-
|
|
558
|
+
/**
|
|
559
|
+
* A param value Bun's native route table matched but the portable router must decide: an invalid
|
|
560
|
+
* escape, or a piece of path Bun matched raw while its own parsed URL says otherwise - a `.` / `..`
|
|
561
|
+
* segment (Bun hands `%2e` over decoded), or a backslash, which the parsed URL reads as `/`.
|
|
562
|
+
*/
|
|
563
|
+
const unroutedParam = (value: string | undefined): boolean =>
|
|
564
|
+
value !== undefined &&
|
|
565
|
+
(value.includes("\uFFFD") || value.includes("\\") || value === "." || value === "..")
|
|
566
|
+
|
|
567
|
+
function hasUnroutedParam(params: Record<string, string>): boolean {
|
|
465
568
|
for (const key in params) {
|
|
466
|
-
if (params[key]
|
|
569
|
+
if (unroutedParam(params[key])) return true
|
|
467
570
|
}
|
|
468
571
|
return false
|
|
469
572
|
}
|
|
470
573
|
|
|
574
|
+
/**
|
|
575
|
+
* Deno is the runtime that hands an app a `Request` whose `url` is the target as the client sent it;
|
|
576
|
+
* Bun, workerd and every `new Request()` parse it first. Only there does `fetch` read the URL up front.
|
|
577
|
+
*/
|
|
578
|
+
const rawRequestTargets = typeof (globalThis as { Deno?: unknown }).Deno !== "undefined"
|
|
579
|
+
|
|
580
|
+
/** `req` as Bun or workerd would have delivered it: the same request, its URL's dot segments resolved. */
|
|
581
|
+
const withResolvedTarget = (req: Request): Request => new Request(resolveDotSegments(req.url), req)
|
|
582
|
+
|
|
471
583
|
function normalizeMountPrefix(path: string): string {
|
|
472
584
|
if (!path.startsWith("/") || path.includes("?") || path.includes("#")) {
|
|
473
585
|
throw new TypeError("mountFetch path must be an absolute pathname without a query or hash")
|
|
@@ -486,6 +598,18 @@ function underMountPrefix(pathname: string, prefix: string): boolean {
|
|
|
486
598
|
return prefix === "/" || pathname === prefix || pathname.startsWith(`${prefix}/`)
|
|
487
599
|
}
|
|
488
600
|
|
|
601
|
+
function mountPriorityOf(priority: number | undefined): number {
|
|
602
|
+
if (priority === undefined) return 0
|
|
603
|
+
if (!Number.isSafeInteger(priority)) {
|
|
604
|
+
throw new TypeError("mount priority must be a safe integer")
|
|
605
|
+
}
|
|
606
|
+
return priority
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
function compareMounts<Env>(a: FetchMount<Env>, b: FetchMount<Env>): number {
|
|
610
|
+
return b.priority - a.priority || b.path.length - a.path.length || a.order - b.order
|
|
611
|
+
}
|
|
612
|
+
|
|
489
613
|
function stripMountPrefix(request: Request, prefix: string): Request {
|
|
490
614
|
if (prefix === "/") return request
|
|
491
615
|
const url = new URL(request.url)
|
|
@@ -535,6 +659,88 @@ export type NifraFeatureVersion = FeatureVersionOf<Version>
|
|
|
535
659
|
const webResponseOf = (result: Response | ResponseResult): Response =>
|
|
536
660
|
result instanceof Response ? result : toResponse(result, EMPTY_RESPONSE_CONTROLS)
|
|
537
661
|
|
|
662
|
+
/** Which requests a scoped (group/merged) hook runs for, judged from the request the hook sees. */
|
|
663
|
+
type HookScope = (req: { readonly method: string; readonly url: string }) => boolean
|
|
664
|
+
|
|
665
|
+
/** A `group(prefix)` hook's scope: every request whose path is the prefix or lies under it - served,
|
|
666
|
+
* 404, 405, or preflight alike (Hono's `use("/api/*")` reach). The router matches static segments
|
|
667
|
+
* byte-exactly and case-sensitively, with the same path scanner used here, so no spelling a group
|
|
668
|
+
* route answers to can fall outside this test. */
|
|
669
|
+
const underPrefix = (prefix: string): HookScope => {
|
|
670
|
+
const nested = `${prefix}/`
|
|
671
|
+
return (req) => {
|
|
672
|
+
const path = pathnameOf(req.url)
|
|
673
|
+
return path === prefix || path.startsWith(nested)
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
/** A merged server's hook scope: exactly the routes MERGED, snapshotted into a dedicated matcher so
|
|
678
|
+
* the guard never reflects whatever the source's own catalog grows into afterwards. */
|
|
679
|
+
const servedBy = (routes: readonly CatalogRoute[]): HookScope => {
|
|
680
|
+
const scope = new RouteCatalog()
|
|
681
|
+
scope.addBatch(routes)
|
|
682
|
+
return (req) => scope.find(req.method, pathnameOf(req.url)).found
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
type AdoptedHook = ((first: never, second: never) => unknown) | undefined
|
|
686
|
+
|
|
687
|
+
/** Gate adopted hooks to `inScope` (`undefined`: adopt them unchanged). The request is a request
|
|
688
|
+
* hook's first argument (`reqAt` 0) and every response-side hook's second; out of scope a hook
|
|
689
|
+
* declines, except a Web response hook, which must hand the response on (`passFirst`). An
|
|
690
|
+
* `undefined` slot marks an unpaired Node twin, position-aligned with its Web list, and stays
|
|
691
|
+
* `undefined` - wrapping it would fabricate a twin that never existed. */
|
|
692
|
+
const scopeHooks = <H extends AdoptedHook>(
|
|
693
|
+
hooks: readonly H[],
|
|
694
|
+
inScope: HookScope | undefined,
|
|
695
|
+
reqAt: 0 | 1,
|
|
696
|
+
passFirst?: true,
|
|
697
|
+
): readonly H[] =>
|
|
698
|
+
inScope === undefined
|
|
699
|
+
? hooks
|
|
700
|
+
: hooks.map((hook) =>
|
|
701
|
+
hook === undefined
|
|
702
|
+
? hook
|
|
703
|
+
: (((first: never, second: never) =>
|
|
704
|
+
inScope(reqAt === 0 ? first : second)
|
|
705
|
+
? hook(first, second)
|
|
706
|
+
: passFirst && first) as H),
|
|
707
|
+
)
|
|
708
|
+
|
|
709
|
+
/** A `group()` prefix: `/`-led segments of RFC 3986 `pchar` minus `%` and `:`, none of them `.` or
|
|
710
|
+
* `..`. That is text the router compares byte-for-byte as static, so a prefix can never smuggle a
|
|
711
|
+
* param, a wildcard, or an escape, and never spells a path (dot or empty segment, trailing slash)
|
|
712
|
+
* that no normalized client URL reaches. Segments are delimited by a `/` the class excludes, so the
|
|
713
|
+
* match is linear. */
|
|
714
|
+
const GROUP_PREFIX = /^(?:\/(?!\.\.?(?:\/|$))[\w.~!$&'()+,;=@-]+)+$/
|
|
715
|
+
|
|
716
|
+
/** The order-scoped chain a group scope inherits - copied, so what the group adds stays its own. */
|
|
717
|
+
const GROUP_CHAIN = [
|
|
718
|
+
"derives",
|
|
719
|
+
"authStages",
|
|
720
|
+
"beforeHandleHooks",
|
|
721
|
+
"afterHandleHooks",
|
|
722
|
+
"onErrorHooks",
|
|
723
|
+
"aroundHooks",
|
|
724
|
+
"activeAssurance",
|
|
725
|
+
// Inherited so a group route computes `authenticated` (idempotency principal scoping) exactly as a
|
|
726
|
+
// parent route would; only the evidence the group adds is folded at adoption.
|
|
727
|
+
"globalAssurance",
|
|
728
|
+
"capabilityInterceptors",
|
|
729
|
+
"capabilityObservers",
|
|
730
|
+
] as const
|
|
731
|
+
|
|
732
|
+
/** Installed runtimes: a group scope inherits them, and adoption hoists a source's into a server that
|
|
733
|
+
* has none, so composing servers cannot silently disable a safety lane. */
|
|
734
|
+
const SERVER_RUNTIMES = [
|
|
735
|
+
"responseContractRuntime",
|
|
736
|
+
"idempotencyRuntime",
|
|
737
|
+
"effectLedgerRuntime",
|
|
738
|
+
"mcpRuntime",
|
|
739
|
+
"nodeOutcomeRuntime",
|
|
740
|
+
"sseRuntime",
|
|
741
|
+
"wsRuntime",
|
|
742
|
+
] as const
|
|
743
|
+
|
|
538
744
|
// Stable module-level finalizers so `fetch`/`resolveNode` allocate no per-request closures.
|
|
539
745
|
const IDENTITY_RESPONSE = (response: Response | ResponseResult): Response => webResponseOf(response)
|
|
540
746
|
const RESPONSE_TIMEOUT = (): Response => jsonError(503, "request_timeout")
|
|
@@ -660,6 +866,10 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
660
866
|
/** WebSocket routes, matched separately at upgrade time (a GET + `Upgrade: websocket`). */
|
|
661
867
|
private readonly wsRouter: Router<WsEntry>
|
|
662
868
|
private wsRouteCount: number
|
|
869
|
+
/** `wsChanges` when `wsMountsLive` was last worked out; -1 until then. */
|
|
870
|
+
private wsMountsCheckedAt = -1
|
|
871
|
+
/** Whether an app mounted here takes WebSocket upgrades, as of `wsMountsCheckedAt`. */
|
|
872
|
+
private wsMountsLive = false
|
|
663
873
|
/** In-process pub/sub backing `ws.subscribe(topic)` + `app.publish(topic, data)` (single-instance).
|
|
664
874
|
* Created by the first `app.ws()` via the `@nifrajs/core/ws` runtime - `undefined` until then, so a
|
|
665
875
|
* no-WebSocket app never constructs (or bundles) it. */
|
|
@@ -669,7 +879,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
669
879
|
* rather than delegate to Bun's native `server.publish`. */
|
|
670
880
|
private wsHasValidatedSend = false
|
|
671
881
|
/** Bun's native topic broadcast, bound by `listen()` in native-pubsub mode; `app.publish` uses it
|
|
672
|
-
* instead of the JS registry. `undefined` off Bun, before `listen()`,
|
|
882
|
+
* instead of the JS registry. `undefined` off Bun, before `listen()`, with a validated-send route,
|
|
883
|
+
* or when a mounted app takes upgrades. */
|
|
673
884
|
private nativePublish:
|
|
674
885
|
| ((topic: string, data: string | ArrayBufferView | ArrayBuffer) => void)
|
|
675
886
|
| undefined
|
|
@@ -679,6 +890,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
679
890
|
private readonly trustBodyFraming: boolean
|
|
680
891
|
private readonly wsMaxPayloadBytes: number
|
|
681
892
|
private readonly requestTimeoutMs: number
|
|
893
|
+
private readonly wsUpgradeTimeoutMs: number
|
|
682
894
|
/** Installed by the `responseContract()` plugin; `undefined` = not installed, which is the default
|
|
683
895
|
* and the state in which a declared `response` schema stays a compile-time contract only. */
|
|
684
896
|
private responseContractRuntime: ResponseContractRuntime | undefined
|
|
@@ -689,7 +901,10 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
689
901
|
private readonly deadlineAdmissionOptions: DeadlineAdmissionOptions
|
|
690
902
|
private readonly gracefulSignals: boolean
|
|
691
903
|
private readonly stopHooks: StopHook[]
|
|
692
|
-
private readonly fetchMounts: FetchMount[]
|
|
904
|
+
private readonly fetchMounts: FetchMount<EnvOf<Ctx>>[]
|
|
905
|
+
private mountOrder: number
|
|
906
|
+
/** Number of composed mounts that must run before the parent route table. Keeps legacy mounts off the hot path. */
|
|
907
|
+
private preRouteMountCount: number
|
|
693
908
|
/** Capacity-admission gate; `undefined` = off (the request path pays nothing). */
|
|
694
909
|
private readonly capacityGate: AdmissionController | undefined
|
|
695
910
|
private readonly onCapabilityUse: ((event: CapabilityUseEvent) => void) | undefined
|
|
@@ -703,6 +918,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
703
918
|
private idempotencyRuntime: IdempotencyRuntime | undefined
|
|
704
919
|
/** Installed opt-in runtime for `.tool()`/`.resource()`/`.prompt()`; `undefined` until `.use(mcp())`. */
|
|
705
920
|
private mcpRuntime: McpRuntime | undefined
|
|
921
|
+
/** Installed answer for a request no route matched; `undefined` (the plain 404) until `.use(notFound())`. */
|
|
922
|
+
private notFoundLane: NotFoundLane | undefined
|
|
706
923
|
/** Installed Node-direct renderer for direct `resolveNode()` callers; `undefined` until `.use(nodeDirect())`. */
|
|
707
924
|
private nodeOutcomeRuntime: NodeOutcomeRuntime | undefined
|
|
708
925
|
/** Installed streaming runtime for `.sse()` routes; `undefined` until `.use(streaming())`. */
|
|
@@ -717,6 +934,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
717
934
|
private bunServer: RunningServer | undefined
|
|
718
935
|
private sealed: boolean
|
|
719
936
|
private readonly derives: RawDerive[]
|
|
937
|
+
/** Dedicated authentication stages captured by subsequent routes. */
|
|
938
|
+
private readonly authStages: RawAuthStage[]
|
|
720
939
|
private readonly decorations: Record<string, unknown>
|
|
721
940
|
private readonly beforeHandleHooks: RawBeforeHandle[]
|
|
722
941
|
private readonly afterHandleHooks: RawAfterHandle[]
|
|
@@ -726,10 +945,14 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
726
945
|
private readonly onNodeRequestHooks: Array<NodeRequestHook | undefined>
|
|
727
946
|
/** Registration-time eligibility for the Node request twin lane. */
|
|
728
947
|
private nodeRequestHooksComplete: boolean
|
|
948
|
+
/** True when every request hook is safe to skip on a Bun native route. */
|
|
949
|
+
private bunNativeRequestHooksSafe: boolean
|
|
729
950
|
private readonly onResponseHooks: RawOnResponse[]
|
|
730
951
|
private readonly onNodeResponseHooks: Array<NodeResponseHook | undefined>
|
|
731
952
|
/** Registration-time eligibility for the Node response twin lane. */
|
|
732
953
|
private nodeResponseHooksComplete: boolean
|
|
954
|
+
/** True when every response hook only mutates headers and can run after a Bun fused response. */
|
|
955
|
+
private bunNativeResponseHeadersOnly: boolean
|
|
733
956
|
/**
|
|
734
957
|
* True once a bundle registers a RAW `onNodeResponse` twin - one handed the outcome's header
|
|
735
958
|
* record itself rather than the case-normalizing view. Their names are contractually the wire
|
|
@@ -745,6 +968,11 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
745
968
|
* `onResponse` hook would have disabled.
|
|
746
969
|
*/
|
|
747
970
|
private staticResponseHeaders: StaticResponseHeaders | undefined
|
|
971
|
+
/** A group's view of the static headers its enclosing scopes declared before it was created. */
|
|
972
|
+
private declare inheritedStatics: Readonly<Record<string, string>> | undefined
|
|
973
|
+
/** A group's view of the plugins its enclosing scopes applied before it was created; unset while
|
|
974
|
+
* a plugin applies, so its own dependency `use()` shares the parent's copy. */
|
|
975
|
+
private declare inheritedPlugins: ReadonlyMap<string, unknown> | undefined
|
|
748
976
|
/** `wrapResponse` for the Web lanes: identity until static headers exist to fold into the
|
|
749
977
|
* framework's own error/404/timeout renders, which are built outside the header init. */
|
|
750
978
|
private wrapWebResponse: (response: Response | ResponseResult) => Response
|
|
@@ -759,8 +987,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
759
987
|
private readonly responseSources: WeakMap<object, Request>
|
|
760
988
|
/** Memoized NodeRequestContext per plain-`Request` source - see {@link nodeRequestContextOf}. */
|
|
761
989
|
private readonly nodeContexts: WeakMap<object, NodeRequestContext>
|
|
762
|
-
/**
|
|
763
|
-
private readonly appliedPlugins:
|
|
990
|
+
/** Plugins/middleware already applied via `use`, by name - for idempotent dedupe. */
|
|
991
|
+
private readonly appliedPlugins: Map<string, unknown>
|
|
764
992
|
/** Order-scoped evidence captured by routes registered after an assured plugin. */
|
|
765
993
|
private readonly activeAssurance: AssuranceDeclaration[]
|
|
766
994
|
/** App-wide evidence from global hooks; applies retroactively to every route. */
|
|
@@ -768,7 +996,14 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
768
996
|
/** App-declared MCP resources / prompts (via {@link resource} / {@link prompt}), read by `nifra mcp`. */
|
|
769
997
|
private readonly mcpResourceList: McpResourceDescriptor[]
|
|
770
998
|
private readonly mcpPromptList: McpPromptDescriptor[]
|
|
999
|
+
/** Kept so a {@link group} scope is built with exactly this server's settings: fused lanes read
|
|
1000
|
+
* several of them at request time, so a group route must never run under different ones. */
|
|
1001
|
+
private readonly options: ServerOptions
|
|
1002
|
+
/** Static prefix joined onto every route this server registers; non-empty only on a group scope. */
|
|
1003
|
+
private routePrefix: string
|
|
771
1004
|
constructor(options: ServerOptions = {}) {
|
|
1005
|
+
this.options = options
|
|
1006
|
+
this.routePrefix = ""
|
|
772
1007
|
this.catalog = new RouteCatalog()
|
|
773
1008
|
this.wsRouter = new Router<WsEntry>()
|
|
774
1009
|
this.wsRouteCount = 0
|
|
@@ -782,6 +1017,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
782
1017
|
this.maxBodyBytes = maxBodyBytes
|
|
783
1018
|
this.wsMaxPayloadBytes = wsMaxPayloadBytes
|
|
784
1019
|
this.requestTimeoutMs = options.requestTimeoutMs ?? 0
|
|
1020
|
+
this.wsUpgradeTimeoutMs = options.wsUpgradeTimeoutMs ?? DEFAULT_WS_UPGRADE_TIMEOUT_MS
|
|
785
1021
|
this.clientIpTrust = options.clientIp
|
|
786
1022
|
this.acceptInboundDeadlines = options.acceptInboundDeadlines ?? false
|
|
787
1023
|
this.maxInboundDeadlineMs = options.maxInboundDeadlineMs ?? 30_000
|
|
@@ -792,12 +1028,17 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
792
1028
|
if (!Number.isFinite(this.requestTimeoutMs) || this.requestTimeoutMs < 0) {
|
|
793
1029
|
throw new RangeError("requestTimeoutMs must be a finite non-negative number")
|
|
794
1030
|
}
|
|
1031
|
+
if (!Number.isFinite(this.wsUpgradeTimeoutMs) || this.wsUpgradeTimeoutMs < 0) {
|
|
1032
|
+
throw new RangeError("wsUpgradeTimeoutMs must be a finite non-negative number")
|
|
1033
|
+
}
|
|
795
1034
|
if (!Number.isFinite(this.maxInboundDeadlineMs) || this.maxInboundDeadlineMs <= 0) {
|
|
796
1035
|
throw new RangeError("maxInboundDeadlineMs must be a finite positive number")
|
|
797
1036
|
}
|
|
798
1037
|
this.gracefulSignals = options.gracefulSignals ?? false
|
|
799
1038
|
this.stopHooks = []
|
|
800
1039
|
this.fetchMounts = []
|
|
1040
|
+
this.mountOrder = 0
|
|
1041
|
+
this.preRouteMountCount = 0
|
|
801
1042
|
this.capacityGate = options.admission
|
|
802
1043
|
this.onCapabilityUse = options.onCapabilityUse
|
|
803
1044
|
this.capabilityInterceptors = []
|
|
@@ -820,6 +1061,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
820
1061
|
beginHookAudit(this, options.unusedScopedHooks ?? "warn", options.logger !== undefined)
|
|
821
1062
|
}
|
|
822
1063
|
this.derives = []
|
|
1064
|
+
this.authStages = []
|
|
823
1065
|
this.decorations = {}
|
|
824
1066
|
this.beforeHandleHooks = []
|
|
825
1067
|
this.afterHandleHooks = []
|
|
@@ -828,9 +1070,11 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
828
1070
|
this.onRequestHooks = []
|
|
829
1071
|
this.onNodeRequestHooks = []
|
|
830
1072
|
this.nodeRequestHooksComplete = true
|
|
1073
|
+
this.bunNativeRequestHooksSafe = true
|
|
831
1074
|
this.onResponseHooks = []
|
|
832
1075
|
this.onNodeResponseHooks = []
|
|
833
1076
|
this.nodeResponseHooksComplete = true
|
|
1077
|
+
this.bunNativeResponseHeadersOnly = true
|
|
834
1078
|
this.hasRawNodeResponseHook = false
|
|
835
1079
|
this.onResponseFinalizedHooks = []
|
|
836
1080
|
this.staticResponseHeaders = undefined
|
|
@@ -841,7 +1085,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
841
1085
|
this.responseRequests = new WeakMap()
|
|
842
1086
|
this.responseSources = new WeakMap()
|
|
843
1087
|
this.nodeContexts = new WeakMap()
|
|
844
|
-
this.appliedPlugins = new
|
|
1088
|
+
this.appliedPlugins = new Map()
|
|
845
1089
|
this.activeAssurance = []
|
|
846
1090
|
this.globalAssurance = []
|
|
847
1091
|
this.mcpResourceList = []
|
|
@@ -852,7 +1096,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
852
1096
|
if (this.sealed) {
|
|
853
1097
|
throw new FrameworkError(
|
|
854
1098
|
"SERVER_SEALED",
|
|
855
|
-
`server configuration is sealed after listen(); call ${operation} before
|
|
1099
|
+
`server configuration is sealed after listen() (a group once its builder returns); call ${operation} before`,
|
|
856
1100
|
)
|
|
857
1101
|
}
|
|
858
1102
|
}
|
|
@@ -877,13 +1121,76 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
877
1121
|
): this {
|
|
878
1122
|
this.assertConfigurable("mountFetch()")
|
|
879
1123
|
if (typeof handler !== "function") throw new TypeError("mountFetch handler must be a function")
|
|
880
|
-
const mount: FetchMount = {
|
|
1124
|
+
const mount: FetchMount<EnvOf<Ctx>> = {
|
|
881
1125
|
path: normalizeMountPrefix(path),
|
|
882
|
-
handler: handler as MountedFetchHandler
|
|
1126
|
+
handler: handler as MountedFetchHandler<EnvOf<Ctx>>,
|
|
1127
|
+
stripPrefix: options.stripPrefix === true,
|
|
1128
|
+
priority: mountPriorityOf(options.priority),
|
|
1129
|
+
fallbackOn404: options.fallbackOn === 404,
|
|
1130
|
+
beforeRoutes: false,
|
|
1131
|
+
order: this.mountOrder++,
|
|
1132
|
+
opaque: options.opaque,
|
|
1133
|
+
}
|
|
1134
|
+
this.fetchMounts.push(mount)
|
|
1135
|
+
this.fetchMounts.sort(compareMounts)
|
|
1136
|
+
return this
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
/**
|
|
1140
|
+
* Compose a child fetch app under a literal path prefix.
|
|
1141
|
+
*
|
|
1142
|
+
* Composed mounts run after the app-global request phase and before the parent's route table. This
|
|
1143
|
+
* makes them safe to use in an SSR shell whose `/*` page route would otherwise swallow an API mount.
|
|
1144
|
+
* If the child exposes `resolveWebSocketUpgrade`, the same mount also participates in adapter-owned
|
|
1145
|
+
* WebSocket upgrades; the serving adapter still upgrades its original socket, while the child sees
|
|
1146
|
+
* the mounted request path (stripped when requested).
|
|
1147
|
+
*
|
|
1148
|
+
* `fallbackOn: 404` is deliberately limited to GET/HEAD/OPTIONS at dispatch time. Those methods are
|
|
1149
|
+
* replayable without buffering an untrusted request body; POST/PUT/PATCH and streaming requests
|
|
1150
|
+
* never get retried after a child has had a chance to consume them.
|
|
1151
|
+
*/
|
|
1152
|
+
mount(options: MountOptions<EnvOf<Ctx>>): this {
|
|
1153
|
+
this.assertConfigurable("mount()")
|
|
1154
|
+
if (options === null || typeof options !== "object") {
|
|
1155
|
+
throw new TypeError("mount() requires { path, app }")
|
|
1156
|
+
}
|
|
1157
|
+
const app = options.app as MountableApp<EnvOf<Ctx>> & Record<symbol, unknown>
|
|
1158
|
+
if (app === null || typeof app !== "object") {
|
|
1159
|
+
throw new TypeError("mount() app must be an object with fetch()")
|
|
1160
|
+
}
|
|
1161
|
+
if (typeof app.fetch !== "function") {
|
|
1162
|
+
throw new TypeError("mount() app.fetch must be a function")
|
|
1163
|
+
}
|
|
1164
|
+
const resolver =
|
|
1165
|
+
typeof app.resolveWebSocketUpgrade === "function"
|
|
1166
|
+
? app.resolveWebSocketUpgrade.bind(app)
|
|
1167
|
+
: undefined
|
|
1168
|
+
// Bun needs a websocket callback table in the parent process. A composed Nifra app names the
|
|
1169
|
+
// runtime its upgrades need through this internal seam, which `listen()` asks (see
|
|
1170
|
+
// `[GET_WS_RUNTIME]`); a resolver without it names none. Adapters that wire standard sockets use
|
|
1171
|
+
// the resolver outcome's own `attach`.
|
|
1172
|
+
const getRuntime = app[GET_WS_RUNTIME] ?? app[NIFRA_BACKEND_WS_RUNTIME]
|
|
1173
|
+
const mount: FetchMount<EnvOf<Ctx>> = {
|
|
1174
|
+
path: normalizeMountPrefix(options.path),
|
|
1175
|
+
handler: app.fetch.bind(app) as MountedFetchHandler<EnvOf<Ctx>>,
|
|
1176
|
+
...(resolver === undefined
|
|
1177
|
+
? {}
|
|
1178
|
+
: {
|
|
1179
|
+
resolveWebSocketUpgrade: resolver,
|
|
1180
|
+
wsRuntime: typeof getRuntime === "function" ? getRuntime.bind(app) : () => null,
|
|
1181
|
+
}),
|
|
883
1182
|
stripPrefix: options.stripPrefix === true,
|
|
1183
|
+
priority: mountPriorityOf(options.priority),
|
|
1184
|
+
fallbackOn404: options.fallbackOn === 404,
|
|
1185
|
+
beforeRoutes: true,
|
|
1186
|
+
order: this.mountOrder++,
|
|
1187
|
+
opaque: options.opaque,
|
|
1188
|
+
app,
|
|
884
1189
|
}
|
|
885
1190
|
this.fetchMounts.push(mount)
|
|
886
|
-
this.fetchMounts.sort(
|
|
1191
|
+
this.fetchMounts.sort(compareMounts)
|
|
1192
|
+
this.preRouteMountCount += 1
|
|
1193
|
+
wsChanges += 1
|
|
887
1194
|
return this
|
|
888
1195
|
}
|
|
889
1196
|
|
|
@@ -921,6 +1228,33 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
921
1228
|
* by none before; one added after the last route reaches nothing
|
|
922
1229
|
* (see {@link ServerOptions.unusedScopedHooks}).
|
|
923
1230
|
*/
|
|
1231
|
+
/**
|
|
1232
|
+
* Register a dedicated authentication stage for subsequent routes.
|
|
1233
|
+
*
|
|
1234
|
+
* Unlike `derive`, this stage receives a body-blind authentication capsule and is compiled before
|
|
1235
|
+
* route validation. Keeping it separate prevents an unrelated derive from accidentally becoming a
|
|
1236
|
+
* security gate and lets the route compiler retain a protected fast lane.
|
|
1237
|
+
*/
|
|
1238
|
+
authenticate<Principal>(
|
|
1239
|
+
stage: AuthenticationStage<Principal, EnvOf<Ctx>>,
|
|
1240
|
+
): Server<R, Ctx & { readonly principal: Principal }, HookOutput> {
|
|
1241
|
+
this.assertConfigurable("authenticate()")
|
|
1242
|
+
if (stage === null || typeof stage !== "object") {
|
|
1243
|
+
throw new TypeError("authenticate() requires an authentication stage")
|
|
1244
|
+
}
|
|
1245
|
+
if (typeof stage.id !== "string" || stage.id.trim() === "") {
|
|
1246
|
+
throw new TypeError("authenticate() stage id must be a non-empty string")
|
|
1247
|
+
}
|
|
1248
|
+
if (typeof stage.run !== "function") {
|
|
1249
|
+
throw new TypeError("authenticate() stage run must be a function")
|
|
1250
|
+
}
|
|
1251
|
+
if (stage.mode !== undefined && stage.mode !== "sync" && stage.mode !== "async") {
|
|
1252
|
+
throw new TypeError('authenticate() stage mode must be "sync" or "async"')
|
|
1253
|
+
}
|
|
1254
|
+
this.authStages.push(stage as unknown as RawAuthStage)
|
|
1255
|
+
return this as unknown as Server<R, Ctx & { readonly principal: Principal }, HookOutput>
|
|
1256
|
+
}
|
|
1257
|
+
|
|
924
1258
|
decorate<const K extends string, V>(key: K, value: V): Server<R, Ctx & Record<K, V>, HookOutput> {
|
|
925
1259
|
this.assertConfigurable("decorate()")
|
|
926
1260
|
this.decorations[key] = value
|
|
@@ -940,6 +1274,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
940
1274
|
this.onRequestHooks.push(fn as RawOnRequest)
|
|
941
1275
|
this.onNodeRequestHooks.push(undefined)
|
|
942
1276
|
this.nodeRequestHooksComplete = false
|
|
1277
|
+
this.bunNativeRequestHooksSafe = false
|
|
943
1278
|
return this
|
|
944
1279
|
}
|
|
945
1280
|
|
|
@@ -1044,6 +1379,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
1044
1379
|
this.onResponseHooks.push(fn)
|
|
1045
1380
|
this.onNodeResponseHooks.push(undefined)
|
|
1046
1381
|
this.nodeResponseHooksComplete = false
|
|
1382
|
+
this.bunNativeResponseHeadersOnly = false
|
|
1047
1383
|
return this
|
|
1048
1384
|
}
|
|
1049
1385
|
|
|
@@ -1061,7 +1397,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
1061
1397
|
* app.responseHeaders({ "x-frame-options": "DENY", "referrer-policy": "no-referrer" })
|
|
1062
1398
|
*
|
|
1063
1399
|
* They are DEFAULTS: a value the request itself produced (`c.set.headers`, or a response hook)
|
|
1064
|
-
* wins, whatever casing it used.
|
|
1400
|
+
* wins, whatever casing it used. In a group, a name the group declares replaces the value its
|
|
1401
|
+
* enclosing scopes declared before the group, on the group's routes. Names are lowercased once here; a non-string value, an invalid
|
|
1065
1402
|
* name, `__proto__`, or a name the render owns (`content-type`, `content-length`,
|
|
1066
1403
|
* `transfer-encoding`, `set-cookie`) throws a `TypeError` at wire-up.
|
|
1067
1404
|
*
|
|
@@ -1095,7 +1432,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
1095
1432
|
// serialized-body marker survives for later `onResponseBody`/`onResponseRaw` observers and the
|
|
1096
1433
|
// Node writer; reconstructing a `Response` here would strip that marker and reclassify the Node
|
|
1097
1434
|
// outcome from `json` to a generic `response`. A guarded foreign response takes its clone path.
|
|
1098
|
-
const statics = buildStaticResponseHeaders(record)
|
|
1435
|
+
const statics = buildStaticResponseHeaders(record, this.inheritedStatics)
|
|
1099
1436
|
this.onResponseHooks.push((response) => applyStaticResponseHeaders(response, statics))
|
|
1100
1437
|
this.onNodeResponseHooks.push(undefined)
|
|
1101
1438
|
this.nodeResponseHooksComplete = false
|
|
@@ -1105,7 +1442,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
1105
1442
|
this.staticResponseHeaders === undefined
|
|
1106
1443
|
? record
|
|
1107
1444
|
: { ...this.staticResponseHeaders.record, ...record }
|
|
1108
|
-
this.staticResponseHeaders = buildStaticResponseHeaders(merged)
|
|
1445
|
+
this.staticResponseHeaders = buildStaticResponseHeaders(merged, this.inheritedStatics)
|
|
1109
1446
|
const statics = this.staticResponseHeaders
|
|
1110
1447
|
this.wrapWebResponse = (response) =>
|
|
1111
1448
|
applyStaticResponseHeaders(webResponseOf(response), statics)
|
|
@@ -1144,6 +1481,16 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
1144
1481
|
return this
|
|
1145
1482
|
}
|
|
1146
1483
|
|
|
1484
|
+
/**
|
|
1485
|
+
* Apply `all()` or `method()` from `@nifrajs/core/methods`: one handler registered under several
|
|
1486
|
+
* methods, or under a method outside the standard seven. The standard methods join the registry.
|
|
1487
|
+
*
|
|
1488
|
+
* Declared first on purpose. The plugin's handler is typed against this app's context, which
|
|
1489
|
+
* TypeScript reads from the first overload it tries.
|
|
1490
|
+
*/
|
|
1491
|
+
use<M extends string, Path extends string, S extends RouteSchema, Output>(
|
|
1492
|
+
plugin: MethodRoutesPlugin<M, Path, S, Output, Ctx>,
|
|
1493
|
+
): Server<WithMethodRoutes<R, M, Path, S, Output, HookOutput>, Ctx, HookOutput>
|
|
1147
1494
|
/** Enable the opt-in portable response observer methods. */
|
|
1148
1495
|
use(plugin: ResponseObserverPlugin): this & ResponseObserverMethods
|
|
1149
1496
|
/**
|
|
@@ -1168,7 +1515,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
1168
1515
|
* Apply a **plugin function** - `(app) => app`, typically built with {@link definePlugin}. It's
|
|
1169
1516
|
* called with `this` and its result is returned, so an inline plugin's `derive`/`decorate` thread
|
|
1170
1517
|
* the added context to handlers defined after `use` (the overload is generic over the concrete
|
|
1171
|
-
* `this`). A named plugin already applied is skipped (idempotent dedupe)
|
|
1518
|
+
* `this`). A named plugin already applied is skipped (idempotent dedupe), however it was configured,
|
|
1519
|
+
* so a plugin meant to stack - a second, stricter guard - needs a name of its own per instance.
|
|
1172
1520
|
*
|
|
1173
1521
|
* If the plugin's return type is unpinned - `Server<any, any>`, as a hand-rolled `(app) => app` or a
|
|
1174
1522
|
* `NifraPlugin<AnyServer, AnyServer>` infers (e.g. an auth plugin whose own types collapsed) - this
|
|
@@ -1189,22 +1537,43 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
1189
1537
|
*/
|
|
1190
1538
|
use<M extends Middleware>(mw: M): Server<R, Ctx, HookOutput | MiddlewareOutputOf<M>>
|
|
1191
1539
|
use(mw: Middleware): this
|
|
1192
|
-
use(
|
|
1540
|
+
use(
|
|
1541
|
+
input:
|
|
1542
|
+
| Middleware
|
|
1543
|
+
| ((app: this) => AnyServer)
|
|
1544
|
+
| MethodRoutesPlugin<string, string, RouteSchema, unknown, Ctx>,
|
|
1545
|
+
): AnyServer {
|
|
1546
|
+
// A method-routes plugin is an ordinary plugin function; only its type is opaque.
|
|
1547
|
+
const arg = input as Middleware | ((app: this) => AnyServer)
|
|
1193
1548
|
this.assertConfigurable("use()")
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
if (name
|
|
1197
|
-
|
|
1198
|
-
|
|
1549
|
+
const name = typeof arg === "function" ? (arg as { pluginName?: string }).pluginName : arg.name
|
|
1550
|
+
if (name !== undefined) {
|
|
1551
|
+
if (this.appliedPlugins.has(name)) {
|
|
1552
|
+
const parent = this.inheritedPlugins?.get(name)
|
|
1553
|
+
// A group's builder passing another instance of a plugin its parent applied would have it
|
|
1554
|
+
// dropped, configuration and all. A plugin's own dependency use() shares the parent's copy.
|
|
1555
|
+
if (parent !== undefined && parent !== arg) {
|
|
1556
|
+
throw new RouteConfigError(
|
|
1557
|
+
"PLUGIN_RECONFIGURED",
|
|
1558
|
+
`use() of ${JSON.stringify(name)} in a group: its parent applied another instance`,
|
|
1559
|
+
)
|
|
1560
|
+
}
|
|
1561
|
+
return this // idempotent: already applied
|
|
1199
1562
|
}
|
|
1563
|
+
this.appliedPlugins.set(name, arg)
|
|
1564
|
+
}
|
|
1565
|
+
if (typeof arg === "function") {
|
|
1200
1566
|
const evidence = assuranceDeclarationsOf(arg)
|
|
1201
1567
|
const pluginOnly = evidence.filter((item) => item.scope === "plugin")
|
|
1202
1568
|
this.globalAssurance.push(...evidence.filter((item) => item.scope === "global"))
|
|
1203
1569
|
this.activeAssurance.push(...evidence.filter((item) => item.scope === "subsequent"))
|
|
1204
1570
|
this.activeAssurance.push(...pluginOnly)
|
|
1571
|
+
const inherited = this.inheritedPlugins
|
|
1572
|
+
this.inheritedPlugins = undefined
|
|
1205
1573
|
try {
|
|
1206
1574
|
return arg(this)
|
|
1207
1575
|
} finally {
|
|
1576
|
+
this.inheritedPlugins = inherited
|
|
1208
1577
|
// Remove only this plugin's temporary evidence. Nested assured plugins may deliberately leave
|
|
1209
1578
|
// subsequent evidence active, so truncating the whole array would lose real ordering semantics.
|
|
1210
1579
|
for (const item of pluginOnly) {
|
|
@@ -1213,10 +1582,6 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
1213
1582
|
}
|
|
1214
1583
|
}
|
|
1215
1584
|
}
|
|
1216
|
-
if (arg.name !== undefined) {
|
|
1217
|
-
if (this.appliedPlugins.has(arg.name)) return this
|
|
1218
|
-
this.appliedPlugins.add(arg.name)
|
|
1219
|
-
}
|
|
1220
1585
|
const evidence = assuranceDeclarationsOf(arg)
|
|
1221
1586
|
if (evidence.some((item) => item.scope === "plugin")) {
|
|
1222
1587
|
throw new Error('route assurance: scope "plugin" may only annotate a plugin function')
|
|
@@ -1239,6 +1604,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
1239
1604
|
this.onRequestHooks.push(arg.onRequest as RawOnRequest)
|
|
1240
1605
|
this.onNodeRequestHooks.push(arg.onNodeRequest)
|
|
1241
1606
|
if (arg.onNodeRequest === undefined) this.nodeRequestHooksComplete = false
|
|
1607
|
+
if (!(arg as unknown as Record<symbol, unknown>)[BUN_NATIVE_REQUEST_SAFE]) {
|
|
1608
|
+
this.bunNativeRequestHooksSafe = false
|
|
1609
|
+
}
|
|
1242
1610
|
} else if (arg.onNodeRequest !== undefined) {
|
|
1243
1611
|
throw new TypeError("onNodeRequest() requires a paired onRequest() hook")
|
|
1244
1612
|
}
|
|
@@ -1251,16 +1619,26 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
1251
1619
|
this.onNodeResponseHooks.push(arg.onNodeResponse)
|
|
1252
1620
|
if (arg.onNodeResponse === undefined) this.nodeResponseHooksComplete = false
|
|
1253
1621
|
else this.hasRawNodeResponseHook = true
|
|
1622
|
+
this.bunNativeResponseHeadersOnly = false
|
|
1254
1623
|
} else if (arg.onNodeResponse !== undefined) {
|
|
1255
1624
|
throw new TypeError("onNodeResponse() requires a paired onResponse() hook")
|
|
1256
1625
|
}
|
|
1257
1626
|
// Before the bundle's own hooks: a bundle declaring both means its static values are the
|
|
1258
1627
|
// defaults its hook may then override, which is the order a single bundle reads in.
|
|
1259
1628
|
if (arg.responseHeaders !== undefined) this.responseHeaders(arg.responseHeaders)
|
|
1260
|
-
if (arg.onResponseHeaders !== undefined)
|
|
1261
|
-
this.responseObserverMethods().onResponseHeaders(
|
|
1262
|
-
|
|
1263
|
-
|
|
1629
|
+
if (arg.onResponseHeaders !== undefined) {
|
|
1630
|
+
this.responseObserverMethods().onResponseHeaders(
|
|
1631
|
+
arg.onResponseHeaders,
|
|
1632
|
+
arg.onNodeResponseHeaders,
|
|
1633
|
+
)
|
|
1634
|
+
} else if (arg.onNodeResponseHeaders !== undefined) {
|
|
1635
|
+
throw new TypeError("onNodeResponseHeaders() requires a paired onResponseHeaders() hook")
|
|
1636
|
+
}
|
|
1637
|
+
if (arg.onResponseBody !== undefined) {
|
|
1638
|
+
this.responseObserverMethods().onResponseBody(arg.onResponseBody, arg.onNodeResponseBody)
|
|
1639
|
+
} else if (arg.onNodeResponseBody !== undefined) {
|
|
1640
|
+
throw new TypeError("onNodeResponseBody() requires a paired onResponseBody() hook")
|
|
1641
|
+
}
|
|
1264
1642
|
if (arg.onResponseRaw !== undefined)
|
|
1265
1643
|
this.responseObserverMethods().onResponseRaw(arg.onResponseRaw)
|
|
1266
1644
|
if (arg.onResponseFinalized !== undefined) this.onResponseFinalized(arg.onResponseFinalized)
|
|
@@ -1589,10 +1967,10 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
1589
1967
|
this.topics ??= runtime.createTopics()
|
|
1590
1968
|
// A `messageSchema` wraps `message` with validation once, here - every adapter then dispatches
|
|
1591
1969
|
// already-validated, typed messages (Bun/Deno/Node/Workers) with no per-adapter code.
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
})
|
|
1970
|
+
const entry = { handler: runtime.wrapHandler(handler as WebSocketHandler, this.protoPoisoning) }
|
|
1971
|
+
for (const form of expandOptionalParams(path)) this.wsRouter.add("GET", form, entry)
|
|
1595
1972
|
this.wsRouteCount += 1
|
|
1973
|
+
wsChanges += 1
|
|
1596
1974
|
return this as never
|
|
1597
1975
|
}
|
|
1598
1976
|
|
|
@@ -1639,34 +2017,54 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
1639
2017
|
* is the "compiled", order-scoped per-route chain.
|
|
1640
2018
|
*/
|
|
1641
2019
|
register(
|
|
1642
|
-
method:
|
|
2020
|
+
method: string,
|
|
1643
2021
|
path: string,
|
|
1644
2022
|
schema: RouteSchema | undefined,
|
|
1645
2023
|
handler: (context: never) => unknown,
|
|
1646
2024
|
): void {
|
|
1647
|
-
this.
|
|
1648
|
-
this.catalog.add(this.prepareRoute(method, path, schema, handler))
|
|
2025
|
+
this.registerBatch([{ method, path, schema, handler }])
|
|
1649
2026
|
}
|
|
1650
2027
|
|
|
1651
2028
|
/** Register a contract/group route batch atomically. Every route captures the same current chain it
|
|
1652
|
-
* would capture through {@link register}; no route becomes visible unless the full batch validates.
|
|
2029
|
+
* would capture through {@link register}; no route becomes visible unless the full batch validates.
|
|
2030
|
+
*
|
|
2031
|
+
* A path ending in optional parameters (`/users/:id?`) is one catalog route per concrete pattern,
|
|
2032
|
+
* so reflection, OpenAPI, evidence and the typed client all see ordinary routes. */
|
|
1653
2033
|
registerBatch(
|
|
1654
2034
|
routes: readonly {
|
|
1655
|
-
readonly method:
|
|
2035
|
+
readonly method: string
|
|
1656
2036
|
readonly path: string
|
|
1657
2037
|
readonly schema: RouteSchema | undefined
|
|
1658
2038
|
readonly handler: (context: never) => unknown
|
|
1659
2039
|
}[],
|
|
1660
2040
|
): void {
|
|
1661
2041
|
this.assertConfigurable("route registration")
|
|
1662
|
-
const staged =
|
|
1663
|
-
|
|
1664
|
-
|
|
2042
|
+
const staged: CatalogRoute[] = []
|
|
2043
|
+
for (const { method, path, schema, handler } of routes) {
|
|
2044
|
+
for (const form of expandOptionalParams(this.prefixed(path))) {
|
|
2045
|
+
staged.push(this.prepareRoute(method, form, schema, handler))
|
|
2046
|
+
}
|
|
2047
|
+
}
|
|
1665
2048
|
this.catalog.addBatch(staged)
|
|
1666
2049
|
}
|
|
1667
2050
|
|
|
2051
|
+
/** Join a group scope's prefix BEFORE compilation, so the pattern, the reflected descriptor, the
|
|
2052
|
+
* capability guard, the ledger record, and assurance path matching all see the one served path. */
|
|
2053
|
+
private prefixed(path: string): string {
|
|
2054
|
+
const prefix = this.routePrefix
|
|
2055
|
+
if (prefix === "") return path
|
|
2056
|
+
if (path === "/") return prefix
|
|
2057
|
+
// Checked here, not left to the pattern compiler: `"/api" + "users"` is a valid-looking path
|
|
2058
|
+
// (`/apiusers`) that would silently escape the group instead of failing.
|
|
2059
|
+
if (path[0] === "/") return prefix + path
|
|
2060
|
+
throw new RouteConfigError(
|
|
2061
|
+
"INVALID_PATH",
|
|
2062
|
+
`group(${JSON.stringify(prefix)}) route path must start with "/": ${JSON.stringify(path)}`,
|
|
2063
|
+
)
|
|
2064
|
+
}
|
|
2065
|
+
|
|
1668
2066
|
private prepareRoute(
|
|
1669
|
-
method:
|
|
2067
|
+
method: string,
|
|
1670
2068
|
path: string,
|
|
1671
2069
|
schema: RouteSchema | undefined,
|
|
1672
2070
|
handler: (context: never) => unknown,
|
|
@@ -1684,6 +2082,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
1684
2082
|
idempotencyRuntime: this.idempotencyRuntime,
|
|
1685
2083
|
responseContractRuntime: this.responseContractRuntime,
|
|
1686
2084
|
derives: this.derives,
|
|
2085
|
+
authStages: this.authStages,
|
|
1687
2086
|
beforeHandleHooks: this.beforeHandleHooks,
|
|
1688
2087
|
afterHandleHooks: this.afterHandleHooks,
|
|
1689
2088
|
onErrorHooks: this.onErrorHooks,
|
|
@@ -1707,15 +2106,10 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
1707
2106
|
lanes,
|
|
1708
2107
|
routeAssurance,
|
|
1709
2108
|
} = compiled
|
|
2109
|
+
if (idempotent !== undefined && this.idempotencyRuntime !== undefined) {
|
|
2110
|
+
handler = this.idempotencyRuntime.wrapHandler(handler)
|
|
2111
|
+
}
|
|
1710
2112
|
const { bare, fusedQuery, fusedBody } = lanes
|
|
1711
|
-
const fusedBodyRunner = fusedBody
|
|
1712
|
-
? this.buildFusedBodyRunner(
|
|
1713
|
-
handler as unknown as InternalHandler,
|
|
1714
|
-
schema?.body as StandardSchemaV1,
|
|
1715
|
-
hasDecorations ? routeDecorations : undefined,
|
|
1716
|
-
bodyLimit ?? UNLIMITED_BODY_BYTES,
|
|
1717
|
-
)
|
|
1718
|
-
: undefined
|
|
1719
2113
|
// Fused lifecycle lanes: derive + before (with or without an after), and body + the same shape.
|
|
1720
2114
|
// The lane selectors in `selectRouteLanes` are exhaustive about the lifecycleHookLane /
|
|
1721
2115
|
// body-derive-before-after eligibility, so we only need to check the lane and the hook counts.
|
|
@@ -1723,6 +2117,46 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
1723
2117
|
const isFusedLifecycle = lifecycleHookLane !== undefined && this.derives.length === 1
|
|
1724
2118
|
const isFusedBodyLifecycle =
|
|
1725
2119
|
lanes.fusedLane === "body-derive-before" || lanes.fusedLane === "body-derive-before-after"
|
|
2120
|
+
// The generic runners below are the Node-direct twins of the fused Web lanes built further
|
|
2121
|
+
// down: same single-frame lifecycle, finalizing through the caller's own `finalize` instead of
|
|
2122
|
+
// building a `Response`, so the `!webFast` dispatcher prefers them over the generic program
|
|
2123
|
+
// exactly as it prefers the body-only runner. Only the original body-only lane may feed the Web
|
|
2124
|
+
// wrapper; every other runner here preserves its derive/before/after stages.
|
|
2125
|
+
const fusedBodyRunner = fusedBody
|
|
2126
|
+
? this.buildFusedBodyRunner(
|
|
2127
|
+
handler as unknown as InternalHandler,
|
|
2128
|
+
schema?.body as StandardSchemaV1,
|
|
2129
|
+
hasDecorations ? routeDecorations : undefined,
|
|
2130
|
+
bodyLimit ?? UNLIMITED_BODY_BYTES,
|
|
2131
|
+
)
|
|
2132
|
+
: isFusedBodyLifecycle
|
|
2133
|
+
? this.buildFusedBodyDeriveBeforeAfterRunner(
|
|
2134
|
+
handler as unknown as InternalHandler,
|
|
2135
|
+
this.derives[0]!,
|
|
2136
|
+
this.beforeHandleHooks[0]!,
|
|
2137
|
+
this.afterHandleHooks[0],
|
|
2138
|
+
schema?.body as StandardSchemaV1,
|
|
2139
|
+
bodyLimit ?? UNLIMITED_BODY_BYTES,
|
|
2140
|
+
)
|
|
2141
|
+
: isFusedLifecycle && lifecycleHookLane === "derive-before"
|
|
2142
|
+
? this.buildFusedDeriveBeforeRunner(
|
|
2143
|
+
handler as unknown as InternalHandler,
|
|
2144
|
+
this.derives[0]!,
|
|
2145
|
+
this.beforeHandleHooks[0]!,
|
|
2146
|
+
undefined,
|
|
2147
|
+
schema?.query,
|
|
2148
|
+
bodyLimit ?? UNLIMITED_BODY_BYTES,
|
|
2149
|
+
)
|
|
2150
|
+
: isFusedLifecycle && lifecycleHookLane === "derive-before-after"
|
|
2151
|
+
? this.buildFusedDeriveBeforeRunner(
|
|
2152
|
+
handler as unknown as InternalHandler,
|
|
2153
|
+
this.derives[0]!,
|
|
2154
|
+
this.beforeHandleHooks[0]!,
|
|
2155
|
+
this.afterHandleHooks[0],
|
|
2156
|
+
schema?.query,
|
|
2157
|
+
bodyLimit ?? UNLIMITED_BODY_BYTES,
|
|
2158
|
+
)
|
|
2159
|
+
: undefined
|
|
1726
2160
|
const fusedWeb =
|
|
1727
2161
|
bare && this.aroundHooks.length === 0
|
|
1728
2162
|
? this.buildFusedWeb(
|
|
@@ -1771,6 +2205,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
1771
2205
|
schema,
|
|
1772
2206
|
handler: handler as unknown as InternalHandler,
|
|
1773
2207
|
derives: this.derives,
|
|
2208
|
+
authStages: compiled.authStages,
|
|
1774
2209
|
beforeHandle: this.beforeHandleHooks,
|
|
1775
2210
|
afterHandle: this.afterHandleHooks,
|
|
1776
2211
|
onError: this.onErrorHooks,
|
|
@@ -1799,6 +2234,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
1799
2234
|
ledgered,
|
|
1800
2235
|
responseContract,
|
|
1801
2236
|
derives: [...this.derives],
|
|
2237
|
+
authStages: [...compiled.authStages],
|
|
1802
2238
|
decorations: routeDecorations,
|
|
1803
2239
|
hasDecorations,
|
|
1804
2240
|
beforeHandle: [...this.beforeHandleHooks],
|
|
@@ -1813,6 +2249,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
1813
2249
|
method,
|
|
1814
2250
|
path,
|
|
1815
2251
|
schema,
|
|
2252
|
+
...(responseContract === undefined
|
|
2253
|
+
? {}
|
|
2254
|
+
: { responseContract: responseContract.runtime.mode }),
|
|
1816
2255
|
...(capabilities.length > 0 ? { capabilities } : {}),
|
|
1817
2256
|
...(schema?.family === true ? { family: true } : {}),
|
|
1818
2257
|
}
|
|
@@ -1861,10 +2300,11 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
1861
2300
|
this.assertConfigurable("responseObserver()")
|
|
1862
2301
|
const methods = runtime.install({
|
|
1863
2302
|
assertConfigurable: (operation) => this.assertConfigurable(operation),
|
|
1864
|
-
addResponseHook: (web, node) => {
|
|
2303
|
+
addResponseHook: (web, node, headerOnly = false) => {
|
|
1865
2304
|
this.onResponseHooks.push(web)
|
|
1866
2305
|
this.onNodeResponseHooks.push(node)
|
|
1867
2306
|
if (node === undefined) this.nodeResponseHooksComplete = false
|
|
2307
|
+
if (!headerOnly) this.bunNativeResponseHeadersOnly = false
|
|
1868
2308
|
},
|
|
1869
2309
|
enableResponseBodyTagging: () => {
|
|
1870
2310
|
if (this.responseBodyTag === undefined) {
|
|
@@ -1891,6 +2331,12 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
1891
2331
|
this.mcpRuntime = runtime
|
|
1892
2332
|
}
|
|
1893
2333
|
|
|
2334
|
+
/** @internal Symbol-keyed install seam for the `notFound()` plugin. Off the public typed surface. */
|
|
2335
|
+
[INSTALL_NOT_FOUND](lane: NotFoundLane): void {
|
|
2336
|
+
this.assertConfigurable("notFound()")
|
|
2337
|
+
this.notFoundLane = lane
|
|
2338
|
+
}
|
|
2339
|
+
|
|
1894
2340
|
/** @internal Symbol-keyed install seam for the `nodeDirect()` plugin. Off the public typed surface. */
|
|
1895
2341
|
[INSTALL_NODE_DIRECT](runtime: NodeOutcomeRuntime): void {
|
|
1896
2342
|
this.assertConfigurable("nodeDirect()")
|
|
@@ -1909,6 +2355,41 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
1909
2355
|
this.wsRuntime = runtime
|
|
1910
2356
|
}
|
|
1911
2357
|
|
|
2358
|
+
/**
|
|
2359
|
+
* @internal The runtime Bun serves this app's WebSocket upgrades with, asked by `listen()` and by a
|
|
2360
|
+
* parent that mounts this app: `undefined` while nothing here can take one, `null` when something
|
|
2361
|
+
* can but no runtime is at hand. A mounted Nifra app answers through this same seam, so mounting an
|
|
2362
|
+
* app without a WebSocket route needs no runtime; a resolver without the seam cannot say, so it
|
|
2363
|
+
* counts. `seen` ends a mount cycle (an app mounted under itself to alias a prefix).
|
|
2364
|
+
*/
|
|
2365
|
+
[GET_WS_RUNTIME](seen?: Set<unknown>): WsRuntime | null | undefined {
|
|
2366
|
+
return this.wsRouteCount > 0 ? this.wsRuntime : this.mountedWsRuntime(seen)
|
|
2367
|
+
}
|
|
2368
|
+
|
|
2369
|
+
/** The `[GET_WS_RUNTIME]` answer for the apps mounted here alone, whatever this app's own routes. */
|
|
2370
|
+
private mountedWsRuntime(seen: Set<unknown> = new Set()): WsRuntime | null | undefined {
|
|
2371
|
+
if (seen.has(this)) return
|
|
2372
|
+
seen.add(this)
|
|
2373
|
+
let found: WsRuntime | null | undefined
|
|
2374
|
+
for (const mount of this.fetchMounts) {
|
|
2375
|
+
const runtime = mount.wsRuntime?.(seen)
|
|
2376
|
+
if (runtime === undefined) continue
|
|
2377
|
+
found = this.wsRuntime ?? runtime
|
|
2378
|
+
if (found) return found
|
|
2379
|
+
}
|
|
2380
|
+
return found
|
|
2381
|
+
}
|
|
2382
|
+
|
|
2383
|
+
/** Whether an app mounted here takes WebSocket upgrades. Worked out again only after a `ws()` or a
|
|
2384
|
+
* `mount()` somewhere, so the upgrade check on a plain request costs one comparison. */
|
|
2385
|
+
private mountsTakeUpgrades(): boolean {
|
|
2386
|
+
if (this.wsMountsCheckedAt !== wsChanges) {
|
|
2387
|
+
this.wsMountsLive = this.mountedWsRuntime() !== undefined
|
|
2388
|
+
this.wsMountsCheckedAt = wsChanges
|
|
2389
|
+
}
|
|
2390
|
+
return this.wsMountsLive
|
|
2391
|
+
}
|
|
2392
|
+
|
|
1912
2393
|
/**
|
|
1913
2394
|
* Merge another server's routes into this one - the composition escape hatch for large apps.
|
|
1914
2395
|
*
|
|
@@ -1937,6 +2418,19 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
1937
2418
|
other: Server<R2, Ctx2, HookOutput2>,
|
|
1938
2419
|
): Server<R & R2, Ctx, HookOutput> {
|
|
1939
2420
|
this.assertConfigurable("merge()")
|
|
2421
|
+
if (!(other instanceof Server)) {
|
|
2422
|
+
// A server from another copy of this package reaches this copy's private request state through
|
|
2423
|
+
// symbols it does not share, and would fail per request. The boot warning names the copies.
|
|
2424
|
+
throw new TypeError("merge() requires a server() from this copy of @nifrajs/core")
|
|
2425
|
+
}
|
|
2426
|
+
if (this.routePrefix !== "") {
|
|
2427
|
+
// The merged routes were compiled under their own paths; adopting them here would publish them
|
|
2428
|
+
// OUTSIDE the prefix while looking like part of the group.
|
|
2429
|
+
throw new RouteConfigError(
|
|
2430
|
+
"INVALID_PATH",
|
|
2431
|
+
"merge() inside a group cannot prefix compiled routes - merge on the parent",
|
|
2432
|
+
)
|
|
2433
|
+
}
|
|
1940
2434
|
const source = other as unknown as Server<Registry, EmptyContext>
|
|
1941
2435
|
if (source.wsRouteCount > 0) {
|
|
1942
2436
|
throw new RouteConfigError(
|
|
@@ -1944,83 +2438,190 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
1944
2438
|
"merge() does not carry WebSocket routes - register .ws() routes on the parent server",
|
|
1945
2439
|
)
|
|
1946
2440
|
}
|
|
2441
|
+
this.adopt(source, undefined, 0)
|
|
2442
|
+
return this as unknown as Server<R & R2, Ctx, HookOutput>
|
|
2443
|
+
}
|
|
2444
|
+
|
|
2445
|
+
/**
|
|
2446
|
+
* Declare routes under a static path prefix, with the prefix in the typed client, `routes()`,
|
|
2447
|
+
* OpenAPI, capability events, and the effect ledger - the counterpart of Hono's `route()`,
|
|
2448
|
+
* Elysia's `group()`, and Fastify's `register({ prefix })`.
|
|
2449
|
+
*
|
|
2450
|
+
* app
|
|
2451
|
+
* .derive(session)
|
|
2452
|
+
* .group("/admin", (admin) =>
|
|
2453
|
+
* admin.beforeHandle(requireAdmin).get("/users", listUsers).get("/users/:id", getUser),
|
|
2454
|
+
* )
|
|
2455
|
+
* .get("/", home) // not covered by requireAdmin
|
|
2456
|
+
*
|
|
2457
|
+
* The builder receives a scope that INHERITS this server's route chain as it stands at the call
|
|
2458
|
+
* (`derive`/`decorate`/`authenticate`/`beforeHandle`/`afterHandle`/`onError`/`around`, assurance,
|
|
2459
|
+
* capability interceptors, installed runtimes, applied plugins), exactly as a route declared here
|
|
2460
|
+
* would. A plugin this server already applied is in effect for the group: the group's `use()` of
|
|
2461
|
+
* that same instance does nothing, and its `use()` of another instance under the same name throws a
|
|
2462
|
+
* `RouteConfigError` (`PLUGIN_RECONFIGURED`), because that instance could never replace the parent's.
|
|
2463
|
+
* A plugin the group applies still shares the parent's copy of a dependency it `use()`s itself.
|
|
2464
|
+
* What it adds stays in the group: its chain additions apply only to its own routes, and its
|
|
2465
|
+
* request/response hooks (`onRequest`, `onResponse`, `responseHeaders`, `onResponseFinalized`, and
|
|
2466
|
+
* middleware bundles that use them) run only for requests whose path is the prefix or below it.
|
|
2467
|
+
* Per-route authorization belongs in `authenticate`/`beforeHandle`, which are compiled into each
|
|
2468
|
+
* route; request hooks are path prefilters.
|
|
2469
|
+
*
|
|
2470
|
+
* The prefix is static text - `"/api"`, `"/api/v1"` - validated when declared: no params,
|
|
2471
|
+
* wildcards, empty or dot segments, percent-escapes, or trailing slash. A group's `"/"` route
|
|
2472
|
+
* serves the prefix itself. Groups nest (`"/api"` then `"/v1"` serves `/api/v1/...`).
|
|
2473
|
+
*
|
|
2474
|
+
* Fail closed: the builder must synchronously return the scope it was given; a collision with an
|
|
2475
|
+
* existing route throws `RouteConfigError` and adds none of the group's routes; `.ws()`, mounts,
|
|
2476
|
+
* MCP `.tool()`s, and `merge()` inside a group are refused (register them on the parent). Once the
|
|
2477
|
+
* builder returns, the scope is closed - a leaked reference cannot add routes later.
|
|
2478
|
+
*/
|
|
2479
|
+
group<const P extends string, R2 extends Registry, Ctx2, HookOutput2>(
|
|
2480
|
+
prefix: P,
|
|
2481
|
+
build: (group: Server<EmptyRegistry, Ctx, HookOutput>) => Server<R2, Ctx2, HookOutput2>,
|
|
2482
|
+
): Server<R & PrefixRegistry<P, R2>, Ctx, HookOutput> {
|
|
2483
|
+
this.assertConfigurable("group()")
|
|
2484
|
+
if (typeof prefix !== "string" || !GROUP_PREFIX.test(prefix)) {
|
|
2485
|
+
throw new RouteConfigError(
|
|
2486
|
+
"INVALID_PATH",
|
|
2487
|
+
`group() prefix must be a static path like "/api/v1": ${JSON.stringify(prefix)}`,
|
|
2488
|
+
)
|
|
2489
|
+
}
|
|
2490
|
+
const fullPrefix = this.routePrefix + prefix
|
|
2491
|
+
const scope = new Server<Registry, EmptyContext>(this.options)
|
|
2492
|
+
scope.routePrefix = fullPrefix
|
|
2493
|
+
scope.inheritedStatics = { ...this.inheritedStatics, ...this.staticResponseHeaders?.record }
|
|
2494
|
+
for (const key of GROUP_CHAIN) (scope[key] as unknown[]).push(...this[key])
|
|
2495
|
+
for (const key of SERVER_RUNTIMES)
|
|
2496
|
+
(scope as unknown as Record<string, unknown>)[key] = this[key]
|
|
2497
|
+
Object.assign(scope.decorations, this.decorations)
|
|
2498
|
+
// A plugin already applied here is already in effect for the group (its chain was copied above
|
|
2499
|
+
// and its request hooks are app-wide), so re-applying it would run it twice per request. The
|
|
2500
|
+
// group's own plugins are NOT copied back: they are scoped, and a parent `use()` of the same
|
|
2501
|
+
// plugin must still apply it to the parent's routes. The response observer is the exception: it
|
|
2502
|
+
// installs methods bound to the server it is applied to, so the scope must be free to install its
|
|
2503
|
+
// own (re-applying it registers no hook, so nothing can run twice).
|
|
2504
|
+
for (const [name, plugin] of this.appliedPlugins) {
|
|
2505
|
+
if (name !== "nifra:response-observer") scope.appliedPlugins.set(name, plugin)
|
|
2506
|
+
}
|
|
2507
|
+
scope.inheritedPlugins = new Map(scope.appliedPlugins)
|
|
2508
|
+
let built: unknown
|
|
2509
|
+
try {
|
|
2510
|
+
built = build(scope as never)
|
|
2511
|
+
// Only `listen()` seals a server, so a scope sealed here was served on its own - without this
|
|
2512
|
+
// server's request hooks - which a group must never be.
|
|
2513
|
+
if (scope.sealed) built = undefined
|
|
2514
|
+
} finally {
|
|
2515
|
+
// Closed on every exit, including a throw: an async continuation or a leaked reference must
|
|
2516
|
+
// not register routes that nothing will ever adopt.
|
|
2517
|
+
scope.sealed = true
|
|
2518
|
+
}
|
|
2519
|
+
const label = `group(${JSON.stringify(fullPrefix)})`
|
|
2520
|
+
if (built !== scope) {
|
|
2521
|
+
throw new TypeError(`${label} builder must return its group synchronously`)
|
|
2522
|
+
}
|
|
2523
|
+
if (
|
|
2524
|
+
scope.wsRouteCount > 0 ||
|
|
2525
|
+
scope.fetchMounts.length > 0 ||
|
|
2526
|
+
scope.catalog.entries().some((route) => route.descriptor.tool !== undefined)
|
|
2527
|
+
) {
|
|
2528
|
+
throw new RouteConfigError(
|
|
2529
|
+
"INVALID_PATH",
|
|
2530
|
+
`${label} cannot hold ws(), mount, or MCP tool routes - add them to the parent`,
|
|
2531
|
+
)
|
|
2532
|
+
}
|
|
2533
|
+
if (hookAuditRuntime && process.env.NODE_ENV !== "production")
|
|
2534
|
+
sealHookAudit(scope, scope.catalog.size, this.logger)
|
|
2535
|
+
this.adopt(scope, fullPrefix, this.globalAssurance.length)
|
|
2536
|
+
return this as unknown as Server<R & PrefixRegistry<P, R2>, Ctx, HookOutput>
|
|
2537
|
+
}
|
|
2538
|
+
|
|
2539
|
+
/**
|
|
2540
|
+
* Take over another server's routes and hooks - the shared core of {@link merge} and {@link group}.
|
|
2541
|
+
* `prefix` selects group semantics: hooks are scoped to requests under the prefix (response hooks
|
|
2542
|
+
* included), and the source's global assurance past `inheritedGlobal` (what it copied from this
|
|
2543
|
+
* server) is folded into its routes. Without a prefix, merge semantics: request hooks are scoped to
|
|
2544
|
+
* the source's routes when it has any, response hooks are appended app-wide.
|
|
2545
|
+
*/
|
|
2546
|
+
private adopt(
|
|
2547
|
+
source: Server<Registry, EmptyContext>,
|
|
2548
|
+
prefix: string | undefined,
|
|
2549
|
+
inheritedGlobal: number,
|
|
2550
|
+
): void {
|
|
1947
2551
|
const sourceRoutes = source.catalog.entries()
|
|
1948
2552
|
// Hooks and global assurance stay LOCAL to a group that has routes; a route-less group is a
|
|
1949
2553
|
// middleware bundle whose hooks can only mean app-wide intent, so it keeps the global append.
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
2554
|
+
// A prefix group is always local: its hooks mean "under this path", routes or not.
|
|
2555
|
+
const scoped = prefix !== undefined || sourceRoutes.length > 0
|
|
2556
|
+
const ownGlobal = source.globalAssurance.slice(inheritedGlobal)
|
|
2557
|
+
// A group's global assurance rides its (scoped) hooks: folded into each adopted route's own
|
|
2558
|
+
// evidence rather than the parent's global list, so `routes()` never claims the group's
|
|
1953
2559
|
// enforcement for parent routes the group's hooks do not see.
|
|
1954
2560
|
const foldedAssurance =
|
|
1955
|
-
scoped &&
|
|
2561
|
+
scoped && ownGlobal.length > 0
|
|
1956
2562
|
? (route: CatalogRoute): CatalogRoute => ({
|
|
1957
2563
|
...route,
|
|
1958
|
-
assurance: [...route.assurance, ...
|
|
2564
|
+
assurance: [...route.assurance, ...ownGlobal],
|
|
1959
2565
|
})
|
|
1960
2566
|
: (route: CatalogRoute): CatalogRoute => route
|
|
1961
2567
|
this.catalog.addBatch(
|
|
1962
2568
|
sourceRoutes.map((route) => this.bindFusedRuntime(foldedAssurance(route))),
|
|
1963
2569
|
)
|
|
1964
2570
|
// Resolved idempotency/ledger route entries carry their own store/sink configuration, while the
|
|
1965
|
-
// runtime object supplies the generic execution machinery.
|
|
1966
|
-
//
|
|
1967
|
-
//
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
const scope = new RouteCatalog()
|
|
1981
|
-
scope.addBatch(sourceRoutes)
|
|
1982
|
-
this.onRequestHooks.push(
|
|
1983
|
-
...source.onRequestHooks.map(
|
|
1984
|
-
(hook): RawOnRequest =>
|
|
1985
|
-
(req, platform) =>
|
|
1986
|
-
scope.find(req.method, pathnameOf(req.url)).found ? hook(req, platform) : undefined,
|
|
1987
|
-
),
|
|
1988
|
-
)
|
|
1989
|
-
// An `undefined` slot marks an unpaired hook (position-aligned with `onRequestHooks`) and
|
|
1990
|
-
// must stay `undefined` - wrapping it would fabricate a Node twin that never existed.
|
|
1991
|
-
this.onNodeRequestHooks.push(
|
|
1992
|
-
...source.onNodeRequestHooks.map((hook): NodeRequestHook | undefined =>
|
|
1993
|
-
hook === undefined
|
|
1994
|
-
? undefined
|
|
1995
|
-
: (req, platform) =>
|
|
1996
|
-
scope.find(req.method, pathnameOf(req.url)).found ? hook(req, platform) : undefined,
|
|
1997
|
-
),
|
|
1998
|
-
)
|
|
1999
|
-
} else {
|
|
2000
|
-
this.onRequestHooks.push(...source.onRequestHooks)
|
|
2001
|
-
this.onNodeRequestHooks.push(...source.onNodeRequestHooks)
|
|
2002
|
-
}
|
|
2571
|
+
// runtime object supplies the generic execution machinery. If the parent already has a runtime,
|
|
2572
|
+
// either implementation can execute every resolved entry because route-specific options were
|
|
2573
|
+
// pinned during registration.
|
|
2574
|
+
const runtimes = this as unknown as Record<string, unknown>
|
|
2575
|
+
for (const key of SERVER_RUNTIMES) runtimes[key] ??= source[key]
|
|
2576
|
+
// Which requests a scoped hook sees: under the prefix for a group; for a merged server with
|
|
2577
|
+
// routes, a snapshot of exactly the routes MERGED (not whatever its own catalog grows into later).
|
|
2578
|
+
const inScope =
|
|
2579
|
+
prefix !== undefined
|
|
2580
|
+
? underPrefix(prefix)
|
|
2581
|
+
: scoped && source.onRequestHooks.length > 0
|
|
2582
|
+
? servedBy(sourceRoutes)
|
|
2583
|
+
: undefined
|
|
2584
|
+
this.onRequestHooks.push(...scopeHooks(source.onRequestHooks, inScope, 0))
|
|
2585
|
+
this.onNodeRequestHooks.push(...scopeHooks(source.onNodeRequestHooks, inScope, 0))
|
|
2003
2586
|
this.nodeRequestHooksComplete &&= source.nodeRequestHooksComplete
|
|
2004
|
-
|
|
2005
|
-
//
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2587
|
+
this.bunNativeRequestHooksSafe &&= source.bunNativeRequestHooksSafe
|
|
2588
|
+
// A merged server's response hooks are app-wide; a group's run only under its prefix.
|
|
2589
|
+
const responseScope = prefix === undefined ? undefined : inScope
|
|
2590
|
+
const statics = source.staticResponseHeaders
|
|
2591
|
+
if (statics !== undefined) {
|
|
2592
|
+
if (responseScope === undefined) {
|
|
2593
|
+
// The group's static declarations came before its own response hooks, so they are folded in
|
|
2594
|
+
// first - and fold themselves into a hook here if this server already has one (same ordering
|
|
2595
|
+
// rule as a direct `responseHeaders()` call).
|
|
2596
|
+
this.addStaticResponseHeaders({ ...statics.record })
|
|
2597
|
+
} else {
|
|
2598
|
+
// The static tier is folded into EVERY response this server builds, so a group's static
|
|
2599
|
+
// headers cannot join it. A hook ahead of the group's own (the sealed scope is discarded
|
|
2600
|
+
// after adoption) keeps their meaning - defaults a value already on the response wins over.
|
|
2601
|
+
source.onResponseHooks.unshift((response) => applyStaticResponseHeaders(response, statics))
|
|
2602
|
+
source.onNodeResponseHooks.unshift(undefined)
|
|
2603
|
+
source.nodeResponseHooksComplete = false
|
|
2604
|
+
}
|
|
2605
|
+
}
|
|
2606
|
+
this.onResponseHooks.push(...scopeHooks(source.onResponseHooks, responseScope, 1, true))
|
|
2607
|
+
this.onNodeResponseHooks.push(...scopeHooks(source.onNodeResponseHooks, responseScope, 1))
|
|
2608
|
+
this.onResponseFinalizedHooks.push(
|
|
2609
|
+
...scopeHooks(source.onResponseFinalizedHooks, responseScope, 1),
|
|
2610
|
+
)
|
|
2012
2611
|
this.nodeResponseHooksComplete &&= source.nodeResponseHooksComplete
|
|
2612
|
+
this.bunNativeResponseHeadersOnly &&= source.bunNativeResponseHeadersOnly
|
|
2013
2613
|
this.hasRawNodeResponseHook ||= source.hasRawNodeResponseHook
|
|
2014
|
-
this.onResponseFinalizedHooks.push(...source.onResponseFinalizedHooks)
|
|
2015
2614
|
if (source.responseBodyTag !== undefined) {
|
|
2016
2615
|
const owner = this.enableResponseBodyTagging()
|
|
2017
2616
|
this.responseBodyOwners.add(source.responseBodyTag)
|
|
2018
2617
|
source.responseBodyOwners.add(owner)
|
|
2019
2618
|
}
|
|
2020
|
-
if (!scoped) this.globalAssurance.push(...
|
|
2619
|
+
if (!scoped) this.globalAssurance.push(...ownGlobal)
|
|
2620
|
+
// Cleanup the source registered (a pool, a queue consumer) belongs to the server that now runs
|
|
2621
|
+
// its routes; dropping it would leak the resource past `stop()`.
|
|
2622
|
+
this.stopHooks.push(...source.stopHooks)
|
|
2021
2623
|
this.mcpResourceList.push(...source.mcpResourceList)
|
|
2022
2624
|
this.mcpPromptList.push(...source.mcpPromptList)
|
|
2023
|
-
return this as unknown as Server<R & R2, Ctx, HookOutput>
|
|
2024
2625
|
}
|
|
2025
2626
|
|
|
2026
2627
|
/** A fused renderer closes over runtime services to keep its seven-argument JSC fast path. Merging
|
|
@@ -2033,9 +2634,13 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
2033
2634
|
const lane = entry.execution.fusedLane
|
|
2034
2635
|
// Type-erase the lane so each `===` branch sees the full union, not the narrowed remainder.
|
|
2035
2636
|
const laneName: string | undefined = lane
|
|
2036
|
-
// Body+lifecycle routes have a fused Web renderer,
|
|
2037
|
-
//
|
|
2038
|
-
//
|
|
2637
|
+
// Body+lifecycle routes have a fused Web renderer, and - unlike before - a generic fused
|
|
2638
|
+
// runner too: it preserves derive/before/after (it IS the lifecycle, single-framed), so the
|
|
2639
|
+
// Node-direct dispatcher's preference for the generic slot over the execution plan stays
|
|
2640
|
+
// semantics-preserving. Only the original body-only lane may feed the Web wrapper below.
|
|
2641
|
+
const derive = entry.derives[0]
|
|
2642
|
+
const before = entry.beforeHandle[0]
|
|
2643
|
+
const after = entry.afterHandle[0]
|
|
2039
2644
|
const fusedBody =
|
|
2040
2645
|
laneName === "body"
|
|
2041
2646
|
? this.buildFusedBodyRunner(
|
|
@@ -2044,10 +2649,49 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
2044
2649
|
entry.hasDecorations ? entry.decorations : undefined,
|
|
2045
2650
|
entry.bodyLimit ?? UNLIMITED_BODY_BYTES,
|
|
2046
2651
|
)
|
|
2047
|
-
: undefined
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2652
|
+
: laneName === "body-derive-before" && derive !== undefined && before !== undefined
|
|
2653
|
+
? this.buildFusedBodyDeriveBeforeAfterRunner(
|
|
2654
|
+
entry.handler,
|
|
2655
|
+
derive,
|
|
2656
|
+
before,
|
|
2657
|
+
undefined,
|
|
2658
|
+
entry.schema?.body as StandardSchemaV1,
|
|
2659
|
+
entry.bodyLimit ?? UNLIMITED_BODY_BYTES,
|
|
2660
|
+
)
|
|
2661
|
+
: laneName === "body-derive-before-after" &&
|
|
2662
|
+
derive !== undefined &&
|
|
2663
|
+
before !== undefined &&
|
|
2664
|
+
after !== undefined
|
|
2665
|
+
? this.buildFusedBodyDeriveBeforeAfterRunner(
|
|
2666
|
+
entry.handler,
|
|
2667
|
+
derive,
|
|
2668
|
+
before,
|
|
2669
|
+
after,
|
|
2670
|
+
entry.schema?.body as StandardSchemaV1,
|
|
2671
|
+
entry.bodyLimit ?? UNLIMITED_BODY_BYTES,
|
|
2672
|
+
)
|
|
2673
|
+
: laneName === "derive-before" && derive !== undefined && before !== undefined
|
|
2674
|
+
? this.buildFusedDeriveBeforeRunner(
|
|
2675
|
+
entry.handler,
|
|
2676
|
+
derive,
|
|
2677
|
+
before,
|
|
2678
|
+
undefined,
|
|
2679
|
+
entry.schema?.query,
|
|
2680
|
+
entry.bodyLimit ?? UNLIMITED_BODY_BYTES,
|
|
2681
|
+
)
|
|
2682
|
+
: laneName === "derive-before-after" &&
|
|
2683
|
+
derive !== undefined &&
|
|
2684
|
+
before !== undefined &&
|
|
2685
|
+
after !== undefined
|
|
2686
|
+
? this.buildFusedDeriveBeforeRunner(
|
|
2687
|
+
entry.handler,
|
|
2688
|
+
derive,
|
|
2689
|
+
before,
|
|
2690
|
+
after,
|
|
2691
|
+
entry.schema?.query,
|
|
2692
|
+
entry.bodyLimit ?? UNLIMITED_BODY_BYTES,
|
|
2693
|
+
)
|
|
2694
|
+
: undefined
|
|
2051
2695
|
const fusedWeb =
|
|
2052
2696
|
laneName === "body"
|
|
2053
2697
|
? this.buildFusedBodyWeb(fusedBody as FusedBodyRunner)
|
|
@@ -2137,6 +2781,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
2137
2781
|
* Bun/Node/Deno omit it (then `c.env` is `undefined` and `c.waitUntil` runs fire-and-forget).
|
|
2138
2782
|
*/
|
|
2139
2783
|
fetch(req: Request, platform?: Platform<EnvOf<Ctx>>): MaybePromise<Response> {
|
|
2784
|
+
// Deno's `Request.url` is the target as sent; Bun, workerd and every `new Request()` parse it.
|
|
2785
|
+
// Route what the parsed URL says, so `/users/../admin` is `/admin` on every runtime.
|
|
2786
|
+
if (rawRequestTargets && hasDotSegment(req.url)) req = withResolvedTarget(req)
|
|
2140
2787
|
// An edge deployment whose only ingress is this method declares its requests runtime-framed at
|
|
2141
2788
|
// construction; everything else stays on the delivered-byte check (see `trustBodyFraming`).
|
|
2142
2789
|
if (this.trustBodyFraming) markTrustedBodyFraming(req)
|
|
@@ -2192,19 +2839,25 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
2192
2839
|
source: RequestSource,
|
|
2193
2840
|
entry: RouteEntry,
|
|
2194
2841
|
params: Record<string, string>,
|
|
2842
|
+
platform: Platform,
|
|
2195
2843
|
): MaybePromise<Response> {
|
|
2196
|
-
if (this.capacityGate === undefined)
|
|
2197
|
-
|
|
2844
|
+
if (this.capacityGate === undefined) {
|
|
2845
|
+
return this.fetchMatchedInner(source, entry, params, platform)
|
|
2846
|
+
}
|
|
2847
|
+
return this.admitGated(requestOf(source), () =>
|
|
2848
|
+
this.fetchMatchedInner(source, entry, params, platform),
|
|
2849
|
+
)
|
|
2198
2850
|
}
|
|
2199
2851
|
|
|
2200
2852
|
private fetchMatchedInner(
|
|
2201
2853
|
source: RequestSource,
|
|
2202
2854
|
entry: RouteEntry,
|
|
2203
2855
|
params: Record<string, string>,
|
|
2856
|
+
platform: Platform,
|
|
2204
2857
|
): MaybePromise<Response> {
|
|
2205
2858
|
const outcome = this.runMatched(
|
|
2206
2859
|
source,
|
|
2207
|
-
|
|
2860
|
+
platform,
|
|
2208
2861
|
entry,
|
|
2209
2862
|
params,
|
|
2210
2863
|
undefined,
|
|
@@ -2283,18 +2936,30 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
2283
2936
|
|
|
2284
2937
|
const parts = source.urlParts ?? urlPartsOf(source.url)
|
|
2285
2938
|
const match = this.catalog.find(source.method, parts.pathname)
|
|
2286
|
-
if (match.found) return undefined
|
|
2939
|
+
if (match.found || !isRoutableMethod(source.method)) return undefined
|
|
2940
|
+
|
|
2941
|
+
// The mount the ordinary path reaches first: every `mount()` runs before any `mountFetch()`,
|
|
2942
|
+
// whatever their prefix lengths. A 404 fallthrough chain stays on the ordinary path.
|
|
2943
|
+
const mount =
|
|
2944
|
+
this.firstMountUnder(parts.pathname, true) ?? this.firstMountUnder(parts.pathname, false)
|
|
2945
|
+
if (mount === undefined || mount.fallbackOn404) return undefined
|
|
2946
|
+
const candidate = (mount.handler as unknown as Record<symbol, unknown>)[NODE_NATIVE_MOUNT]
|
|
2947
|
+
return typeof candidate === "function"
|
|
2948
|
+
? {
|
|
2949
|
+
handler: candidate as NativeMountHandler,
|
|
2950
|
+
path: mount.path,
|
|
2951
|
+
stripPrefix: mount.stripPrefix,
|
|
2952
|
+
}
|
|
2953
|
+
: undefined
|
|
2954
|
+
}
|
|
2287
2955
|
|
|
2956
|
+
private firstMountUnder(
|
|
2957
|
+
pathname: string,
|
|
2958
|
+
beforeRoutes: boolean,
|
|
2959
|
+
): FetchMount<EnvOf<Ctx>> | undefined {
|
|
2288
2960
|
for (const mount of this.fetchMounts) {
|
|
2289
|
-
if (
|
|
2290
|
-
|
|
2291
|
-
return typeof candidate === "function"
|
|
2292
|
-
? {
|
|
2293
|
-
handler: candidate as NativeMountHandler,
|
|
2294
|
-
path: mount.path,
|
|
2295
|
-
stripPrefix: mount.stripPrefix,
|
|
2296
|
-
}
|
|
2297
|
-
: undefined
|
|
2961
|
+
if (mount.beforeRoutes === beforeRoutes && underMountPrefix(pathname, mount.path))
|
|
2962
|
+
return mount
|
|
2298
2963
|
}
|
|
2299
2964
|
return undefined
|
|
2300
2965
|
}
|
|
@@ -2462,6 +3127,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
2462
3127
|
const context: NodeResponseContext = {
|
|
2463
3128
|
status: outcome.status,
|
|
2464
3129
|
headers,
|
|
3130
|
+
headersAreLowercase: !this.hasRawNodeResponseHook && lowercaseKeys,
|
|
2465
3131
|
cookies: outcome.kind === "json" ? outcome.cookies : undefined,
|
|
2466
3132
|
body: outcome.body,
|
|
2467
3133
|
}
|
|
@@ -2558,124 +3224,294 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
2558
3224
|
* `data`). Runs the route's `upgrade(c)` guard in a real request context. Synchronous unless
|
|
2559
3225
|
* `upgrade()` is async; a throw rejects with a flat 500 (no detail leaked).
|
|
2560
3226
|
*/
|
|
2561
|
-
|
|
3227
|
+
private runWebSocketRequestHooks(
|
|
2562
3228
|
req: Request,
|
|
2563
3229
|
platform?: Platform<EnvOf<Ctx>>,
|
|
2564
|
-
): MaybePromise<
|
|
2565
|
-
|
|
2566
|
-
|
|
2567
|
-
|
|
2568
|
-
|
|
2569
|
-
|
|
2570
|
-
|
|
2571
|
-
|
|
2572
|
-
|
|
2573
|
-
|
|
2574
|
-
const handler = match.payload.handler
|
|
2575
|
-
// Non-null: wsRouteCount > 0 ⇒ ws() ran ⇒ `.use(websocket())` installed the runtime + registry.
|
|
2576
|
-
const pubsub = this.topics as TopicRegistry
|
|
2577
|
-
const attach = (this.wsRuntime as WsRuntime).attach
|
|
2578
|
-
// CSWSH guard, before any per-connection work or the user's upgrade(): reject a disallowed
|
|
2579
|
-
// Origin with 403. Browsers don't CORS-protect WS handshakes but do send cookies, so this
|
|
2580
|
-
// blocks cross-site authenticated sockets when the route opts in via `allowedOrigins`.
|
|
2581
|
-
const origin = req.headers.get("origin")
|
|
2582
|
-
if (handler.allowedOrigins !== undefined) {
|
|
2583
|
-
const allowed =
|
|
2584
|
-
typeof handler.allowedOrigins === "function"
|
|
2585
|
-
? handler.allowedOrigins(origin)
|
|
2586
|
-
: origin !== null && handler.allowedOrigins.includes(origin)
|
|
2587
|
-
if (!allowed) return { kind: "reject", response: jsonError(403, "forbidden_origin") }
|
|
2588
|
-
} else if (origin !== null && !wsSameOrigin(origin, req)) {
|
|
2589
|
-
// Secure default (no explicit `allowedOrigins`): reject a CROSS-ORIGIN browser handshake - the
|
|
2590
|
-
// CSWSH case, since browsers send cookies on WS handshakes and don't apply CORS. Non-browser
|
|
2591
|
-
// clients send no `Origin` and pass; same-origin browsers pass. Set `allowedOrigins` to permit
|
|
2592
|
-
// specific cross-origin clients (or `() => true` for a genuinely public socket).
|
|
2593
|
-
return { kind: "reject", response: jsonError(403, "forbidden_origin") }
|
|
2594
|
-
}
|
|
2595
|
-
if (handler.upgrade === undefined) {
|
|
2596
|
-
return {
|
|
2597
|
-
kind: "upgrade",
|
|
2598
|
-
handler,
|
|
2599
|
-
data: undefined,
|
|
2600
|
-
pubsub,
|
|
2601
|
-
attach,
|
|
2602
|
-
maxPayloadBytes: this.wsMaxPayloadBytes,
|
|
3230
|
+
): MaybePromise<Request | Response> {
|
|
3231
|
+
let current = req
|
|
3232
|
+
for (let i = 0; i < this.onRequestHooks.length; i++) {
|
|
3233
|
+
const hook = this.onRequestHooks[i]
|
|
3234
|
+
if (hook === undefined) return current
|
|
3235
|
+
const outcome = hook(current, platform)
|
|
3236
|
+
if (outcome instanceof Promise) {
|
|
3237
|
+
return outcome.then((first) =>
|
|
3238
|
+
this.continueWebSocketRequestHooks(first, i + 1, current, platform),
|
|
3239
|
+
)
|
|
2603
3240
|
}
|
|
3241
|
+
if (outcome instanceof Request) {
|
|
3242
|
+
current = outcome
|
|
3243
|
+
continue
|
|
3244
|
+
}
|
|
3245
|
+
if (outcome !== undefined) return outcome
|
|
2604
3246
|
}
|
|
2605
|
-
|
|
2606
|
-
const ctx = new RequestContext(
|
|
2607
|
-
req,
|
|
2608
|
-
params,
|
|
2609
|
-
url.search,
|
|
2610
|
-
upgradeSignal,
|
|
2611
|
-
createUnboundedRequestBudget(upgradeSignal),
|
|
2612
|
-
platform,
|
|
2613
|
-
this.maxBodyBytes,
|
|
2614
|
-
this.protoPoisoning,
|
|
2615
|
-
)
|
|
2616
|
-
const settle = (value: unknown): WebSocketUpgradeOutcome =>
|
|
2617
|
-
value instanceof Response
|
|
2618
|
-
? { kind: "reject", response: value }
|
|
2619
|
-
: {
|
|
2620
|
-
kind: "upgrade",
|
|
2621
|
-
handler,
|
|
2622
|
-
data: value,
|
|
2623
|
-
pubsub,
|
|
2624
|
-
attach,
|
|
2625
|
-
maxPayloadBytes: this.wsMaxPayloadBytes,
|
|
2626
|
-
}
|
|
2627
|
-
try {
|
|
2628
|
-
const result = handler.upgrade(ctx as unknown as WebSocketContext<EnvOf<Ctx>>)
|
|
2629
|
-
return result instanceof Promise
|
|
2630
|
-
? result.then(settle, () => ({
|
|
2631
|
-
kind: "reject" as const,
|
|
2632
|
-
response: jsonError(500, "internal_error"),
|
|
2633
|
-
}))
|
|
2634
|
-
: settle(result)
|
|
2635
|
-
} catch {
|
|
2636
|
-
return { kind: "reject", response: jsonError(500, "internal_error") }
|
|
2637
|
-
}
|
|
3247
|
+
return current
|
|
2638
3248
|
}
|
|
2639
3249
|
|
|
2640
|
-
|
|
2641
|
-
|
|
2642
|
-
|
|
2643
|
-
|
|
2644
|
-
|
|
2645
|
-
|
|
2646
|
-
|
|
2647
|
-
|
|
2648
|
-
|
|
2649
|
-
|
|
2650
|
-
if (
|
|
2651
|
-
|
|
2652
|
-
|
|
2653
|
-
|
|
3250
|
+
private async continueWebSocketRequestHooks(
|
|
3251
|
+
first: OnRequestResult,
|
|
3252
|
+
start: number,
|
|
3253
|
+
current: Request,
|
|
3254
|
+
platform?: Platform<EnvOf<Ctx>>,
|
|
3255
|
+
): Promise<Request | Response> {
|
|
3256
|
+
let next = current
|
|
3257
|
+
let outcome = first
|
|
3258
|
+
for (let i = start; ; i++) {
|
|
3259
|
+
if (outcome instanceof Request) next = outcome
|
|
3260
|
+
else if (outcome !== undefined) return outcome
|
|
3261
|
+
if (i >= this.onRequestHooks.length) return next
|
|
3262
|
+
const hook = this.onRequestHooks[i]
|
|
3263
|
+
if (hook === undefined) return next
|
|
3264
|
+
outcome = await hook(next, platform)
|
|
2654
3265
|
}
|
|
2655
|
-
const outcome = this.resolveWebSocketUpgrade(req)
|
|
2656
|
-
return outcome instanceof Promise ? outcome.then(handle) : handle(outcome)
|
|
2657
3266
|
}
|
|
2658
3267
|
|
|
2659
|
-
|
|
2660
|
-
|
|
2661
|
-
|
|
2662
|
-
|
|
2663
|
-
|
|
2664
|
-
|
|
2665
|
-
|
|
2666
|
-
|
|
2667
|
-
|
|
2668
|
-
|
|
2669
|
-
|
|
2670
|
-
|
|
2671
|
-
|
|
3268
|
+
resolveWebSocketUpgrade(
|
|
3269
|
+
req: Request,
|
|
3270
|
+
platform?: Platform<EnvOf<Ctx>>,
|
|
3271
|
+
): MaybePromise<WebSocketUpgradeOutcome> {
|
|
3272
|
+
// Before any header is read: Deno only materializes a request's headers when asked, and that
|
|
3273
|
+
// costs a plain request measurably.
|
|
3274
|
+
if (this.wsRouteCount === 0 && !this.mountsTakeUpgrades()) return WS_PASS
|
|
3275
|
+
if (req.headers.get("upgrade")?.toLowerCase() !== "websocket") return WS_PASS
|
|
3276
|
+
// A WebSocket handshake is a GET (RFC 6455 section 4.1). Any other method with an Upgrade header
|
|
3277
|
+
// is an ordinary request: its HTTP route answers it, and normal routing refuses an unknown token.
|
|
3278
|
+
if (req.method !== "GET") return WS_PASS
|
|
3279
|
+
// The handshake routes the path `fetch` would: dot segments resolved (see `fetch`).
|
|
3280
|
+
if (rawRequestTargets && hasDotSegment(req.url)) req = withResolvedTarget(req)
|
|
3281
|
+
// The handshake's caller is the one `fetch` derives: hooks and `upgrade()` see the trusted IP.
|
|
3282
|
+
platform = this.trustedPlatform(req, platform)
|
|
3283
|
+
|
|
3284
|
+
const timeoutMs =
|
|
3285
|
+
this.wsUpgradeTimeoutMs === 0
|
|
3286
|
+
? 0
|
|
3287
|
+
: this.requestTimeoutMs > 0
|
|
3288
|
+
? Math.min(this.wsUpgradeTimeoutMs, this.requestTimeoutMs)
|
|
3289
|
+
: this.wsUpgradeTimeoutMs
|
|
3290
|
+
const controller = timeoutMs > 0 ? new AbortController() : undefined
|
|
3291
|
+
const signal = controller?.signal ?? getNeverAbortSignal()
|
|
3292
|
+
const deadline = timeoutMs > 0 ? Date.now() + timeoutMs : undefined
|
|
3293
|
+
const timeoutResponse = jsonError(503, "request_timeout")
|
|
3294
|
+
const onTimeout = (): WebSocketUpgradeOutcome => ({
|
|
3295
|
+
kind: "reject",
|
|
3296
|
+
response: timeoutResponse,
|
|
3297
|
+
})
|
|
3298
|
+
const bounded = <T>(work: Promise<T>, onDone: () => T): Promise<T> =>
|
|
3299
|
+
controller === undefined
|
|
3300
|
+
? work
|
|
3301
|
+
: this.withTimeout(work, controller, onDone, Math.max(0, deadline! - Date.now()))
|
|
3302
|
+
|
|
3303
|
+
const resolveMatched = (request: Request): MaybePromise<WebSocketUpgradeOutcome> => {
|
|
3304
|
+
const url = urlPartsOf(request.url)
|
|
3305
|
+
const match = this.wsRouter.find("GET", url.pathname)
|
|
3306
|
+
if (!match.found) return WS_PASS // upgrade header, no WS route here → normal routing decides
|
|
3307
|
+
// Inspect only captured values for escapes. Scanning the full pathname repeated work the router
|
|
3308
|
+
// already did and made every plain dynamic route pay for unrelated static path bytes.
|
|
3309
|
+
const params = match.params === EMPTY_PARAMS ? match.params : decodeRouteParams(match.params)
|
|
3310
|
+
if (params === null) return { kind: "reject", response: jsonError(400, "malformed_path") }
|
|
3311
|
+
const handler = match.payload.handler
|
|
3312
|
+
// Non-null: wsRouteCount > 0 ⇒ ws() ran ⇒ `.use(websocket())` installed the runtime + registry.
|
|
3313
|
+
const pubsub = this.topics as TopicRegistry
|
|
3314
|
+
const attach = (this.wsRuntime as WsRuntime).attach
|
|
3315
|
+
// CSWSH guard, before any per-connection work or the user's upgrade(): reject a disallowed
|
|
3316
|
+
// Origin with 403. Browsers don't CORS-protect WS handshakes but do send cookies, so this
|
|
3317
|
+
// blocks cross-site authenticated sockets when the route opts in via `allowedOrigins`.
|
|
3318
|
+
const origin = request.headers.get("origin")
|
|
3319
|
+
if (handler.allowedOrigins !== undefined) {
|
|
3320
|
+
const allowed =
|
|
3321
|
+
typeof handler.allowedOrigins === "function"
|
|
3322
|
+
? handler.allowedOrigins(origin)
|
|
3323
|
+
: origin !== null && handler.allowedOrigins.includes(origin)
|
|
3324
|
+
if (!allowed) return { kind: "reject", response: jsonError(403, "forbidden_origin") }
|
|
3325
|
+
} else if (origin !== null && !wsSameOrigin(origin, request)) {
|
|
3326
|
+
// Secure default (no explicit `allowedOrigins`): reject a CROSS-ORIGIN browser handshake - the
|
|
3327
|
+
// CSWSH case, since browsers send cookies on WS handshakes and don't apply CORS. Non-browser
|
|
3328
|
+
// clients send no `Origin` and pass; same-origin browsers pass. Set `allowedOrigins` to permit
|
|
3329
|
+
// specific cross-origin clients (or `() => true` for a genuinely public socket).
|
|
3330
|
+
return { kind: "reject", response: jsonError(403, "forbidden_origin") }
|
|
3331
|
+
}
|
|
3332
|
+
if (handler.upgrade === undefined) {
|
|
3333
|
+
return {
|
|
3334
|
+
kind: "upgrade",
|
|
3335
|
+
handler,
|
|
3336
|
+
data: undefined,
|
|
3337
|
+
pubsub,
|
|
3338
|
+
attach,
|
|
3339
|
+
maxPayloadBytes: this.wsMaxPayloadBytes,
|
|
3340
|
+
}
|
|
3341
|
+
}
|
|
3342
|
+
const budget =
|
|
3343
|
+
controller === undefined
|
|
3344
|
+
? createUnboundedRequestBudget(signal)
|
|
3345
|
+
: createRequestBudget({ deadline: deadline!, signal })
|
|
3346
|
+
const ctx = new RequestContext(
|
|
3347
|
+
request,
|
|
3348
|
+
params,
|
|
3349
|
+
url.search,
|
|
3350
|
+
signal,
|
|
3351
|
+
budget,
|
|
3352
|
+
platform,
|
|
3353
|
+
this.maxBodyBytes,
|
|
3354
|
+
this.protoPoisoning,
|
|
3355
|
+
)
|
|
3356
|
+
const settle = (value: unknown): WebSocketUpgradeOutcome =>
|
|
3357
|
+
value instanceof Response
|
|
3358
|
+
? { kind: "reject", response: value }
|
|
3359
|
+
: {
|
|
3360
|
+
kind: "upgrade",
|
|
3361
|
+
handler,
|
|
3362
|
+
data: value,
|
|
3363
|
+
pubsub,
|
|
3364
|
+
attach,
|
|
3365
|
+
maxPayloadBytes: this.wsMaxPayloadBytes,
|
|
3366
|
+
}
|
|
3367
|
+
try {
|
|
3368
|
+
const result = handler.upgrade(ctx as unknown as WebSocketContext<EnvOf<Ctx>>)
|
|
3369
|
+
if (!(result instanceof Promise)) return settle(result)
|
|
3370
|
+
const pending = result.then(settle, () => ({
|
|
3371
|
+
kind: "reject" as const,
|
|
3372
|
+
response: jsonError(500, "internal_error"),
|
|
3373
|
+
}))
|
|
3374
|
+
return bounded(pending, onTimeout)
|
|
3375
|
+
} catch {
|
|
3376
|
+
return { kind: "reject", response: jsonError(500, "internal_error") }
|
|
3377
|
+
}
|
|
3378
|
+
}
|
|
3379
|
+
|
|
3380
|
+
const resolveMounted = (request: Request): MaybePromise<WebSocketUpgradeOutcome> => {
|
|
3381
|
+
// Native pub/sub was chosen because no mounted app took upgrades when `listen()` ran. One that
|
|
3382
|
+
// gains a WebSocket route later stays out: its sockets would share this server's Bun topics.
|
|
3383
|
+
if (this.nativePublish !== undefined) return WS_PASS
|
|
3384
|
+
const pathname = urlPartsOf(request.url).pathname
|
|
3385
|
+
const dispatch = (start: number): MaybePromise<WebSocketUpgradeOutcome> => {
|
|
3386
|
+
let selected: FetchMount<EnvOf<Ctx>> | undefined
|
|
3387
|
+
let selectedIndex = start
|
|
3388
|
+
for (let i = start; i < this.fetchMounts.length; i++) {
|
|
3389
|
+
const candidate = this.fetchMounts[i]!
|
|
3390
|
+
if (
|
|
3391
|
+
candidate.resolveWebSocketUpgrade !== undefined &&
|
|
3392
|
+
underMountPrefix(pathname, candidate.path)
|
|
3393
|
+
) {
|
|
3394
|
+
selected = candidate
|
|
3395
|
+
selectedIndex = i
|
|
3396
|
+
break
|
|
3397
|
+
}
|
|
3398
|
+
}
|
|
3399
|
+
if (selected === undefined) return WS_PASS
|
|
3400
|
+
const childRequest = selected.stripPrefix
|
|
3401
|
+
? stripMountPrefix(request, selected.path)
|
|
3402
|
+
: request
|
|
3403
|
+
let child: MaybePromise<WebSocketUpgradeOutcome>
|
|
3404
|
+
try {
|
|
3405
|
+
child = selected.resolveWebSocketUpgrade!(childRequest, platform)
|
|
3406
|
+
} catch {
|
|
3407
|
+
return { kind: "reject", response: jsonError(500, "internal_error") }
|
|
3408
|
+
}
|
|
3409
|
+
const settle = (outcome: WebSocketUpgradeOutcome): MaybePromise<WebSocketUpgradeOutcome> =>
|
|
3410
|
+
outcome.kind === "pass" ||
|
|
3411
|
+
(outcome.kind === "reject" && outcome.response.status === 404 && selected!.fallbackOn404)
|
|
3412
|
+
? dispatch(selectedIndex + 1)
|
|
3413
|
+
: outcome
|
|
3414
|
+
return child instanceof Promise ? child.then(settle) : settle(child)
|
|
3415
|
+
}
|
|
3416
|
+
return dispatch(0)
|
|
3417
|
+
}
|
|
3418
|
+
|
|
3419
|
+
const resolveRoutes = (request: Request): MaybePromise<WebSocketUpgradeOutcome> => {
|
|
3420
|
+
const local = resolveMatched(request)
|
|
3421
|
+
if (local instanceof Promise) {
|
|
3422
|
+
return local.then((outcome) =>
|
|
3423
|
+
outcome.kind === "pass" ? resolveMounted(request) : outcome,
|
|
3424
|
+
)
|
|
3425
|
+
}
|
|
3426
|
+
return local.kind === "pass" ? resolveMounted(request) : local
|
|
3427
|
+
}
|
|
3428
|
+
|
|
3429
|
+
const resolveWithDeadline = (request: Request): MaybePromise<WebSocketUpgradeOutcome> => {
|
|
3430
|
+
const outcome = resolveRoutes(request)
|
|
3431
|
+
return outcome instanceof Promise ? bounded(outcome, onTimeout) : outcome
|
|
3432
|
+
}
|
|
3433
|
+
|
|
3434
|
+
if (this.onRequestHooks.length === 0) return resolveWithDeadline(req)
|
|
3435
|
+
let hooked: MaybePromise<Request | Response>
|
|
3436
|
+
try {
|
|
3437
|
+
hooked = this.runWebSocketRequestHooks(req, platform)
|
|
3438
|
+
} catch {
|
|
3439
|
+
return { kind: "reject", response: jsonError(500, "internal_error") }
|
|
3440
|
+
}
|
|
3441
|
+
if (!(hooked instanceof Promise)) {
|
|
3442
|
+
return hooked instanceof Response
|
|
3443
|
+
? { kind: "reject", response: hooked }
|
|
3444
|
+
: resolveWithDeadline(hooked)
|
|
3445
|
+
}
|
|
3446
|
+
const pending = hooked.then(
|
|
3447
|
+
(result) =>
|
|
3448
|
+
result instanceof Response
|
|
3449
|
+
? { kind: "reject" as const, response: result }
|
|
3450
|
+
: resolveWithDeadline(result),
|
|
3451
|
+
() => ({ kind: "reject" as const, response: jsonError(500, "internal_error") }),
|
|
3452
|
+
)
|
|
3453
|
+
return bounded(
|
|
3454
|
+
Promise.resolve(pending).then((result) => result),
|
|
3455
|
+
onTimeout,
|
|
3456
|
+
)
|
|
3457
|
+
}
|
|
3458
|
+
|
|
3459
|
+
/** Bun `fetch` when WS routes exist: try a WS upgrade first, else run the normal HTTP lifecycle.
|
|
3460
|
+
* `undefined` ⇒ Bun owns the upgraded socket; a `Response` ⇒ a normal reply or a rejected upgrade.
|
|
3461
|
+
* (The socket dispatch itself lives in `ws-bun.ts`, loaded via `@nifrajs/core/ws`.) */
|
|
3462
|
+
private bunFetchWithWebSocket(
|
|
3463
|
+
req: Request,
|
|
3464
|
+
server: BunUpgradeServer,
|
|
3465
|
+
): MaybePromise<Response | undefined> {
|
|
3466
|
+
const handle = (o: WebSocketUpgradeOutcome): MaybePromise<Response | undefined> => {
|
|
3467
|
+
if (o.kind === "pass")
|
|
3468
|
+
return this.fetch(req, bunPeerPlatform(server, req) as Platform<EnvOf<Ctx>>)
|
|
3469
|
+
if (o.kind === "reject") return o.response
|
|
3470
|
+
return server.upgrade(req, {
|
|
3471
|
+
data: { handler: o.handler, data: o.data, pubsub: o.pubsub },
|
|
3472
|
+
})
|
|
3473
|
+
? undefined
|
|
3474
|
+
: jsonError(426, "upgrade_required")
|
|
3475
|
+
}
|
|
3476
|
+
const outcome = this.resolveWebSocketUpgrade(
|
|
3477
|
+
req,
|
|
3478
|
+
bunPeerPlatform(server, req) as Platform<EnvOf<Ctx>>,
|
|
3479
|
+
)
|
|
3480
|
+
return outcome instanceof Promise ? outcome.then(handle) : handle(outcome)
|
|
3481
|
+
}
|
|
3482
|
+
|
|
3483
|
+
/**
|
|
3484
|
+
* The shared lifecycle, generic over how the final value is rendered: `finalize` turns a handler's
|
|
3485
|
+
* result + `set` into the output `T` (`toResponse` → a Web `Response`; `toNodeOutcome` → node-direct
|
|
3486
|
+
* primitives), `wrapResponse` lifts an early/error `Response` into that same `T`, and `onTimeout`
|
|
3487
|
+
* produces the 503. The Web `fetch` and `resolveNode` are thin callers over this one routing +
|
|
3488
|
+
* context + lifecycle implementation - no duplication across the trust boundary.
|
|
3489
|
+
*/
|
|
3490
|
+
/** Apply the `clientIp` trust declaration to the adapter's raw socket peer, returning a platform
|
|
3491
|
+
* whose `clientIp` is the derived caller. Only called when a trust declaration is configured. */
|
|
3492
|
+
private deriveClientIp(
|
|
3493
|
+
source: RequestSource,
|
|
3494
|
+
platform: Platform<EnvOf<Ctx>> | undefined,
|
|
3495
|
+
): Platform<EnvOf<Ctx>> | undefined {
|
|
2672
3496
|
const derived = resolveClientIp(platform?.clientIp, requestOf(source), this.clientIpTrust)
|
|
2673
3497
|
return { ...platform, clientIp: derived }
|
|
2674
3498
|
}
|
|
2675
3499
|
|
|
3500
|
+
private trustedPlatform(
|
|
3501
|
+
source: RequestSource,
|
|
3502
|
+
platform: Platform<EnvOf<Ctx>> | undefined,
|
|
3503
|
+
): Platform<EnvOf<Ctx>> | undefined {
|
|
3504
|
+
return this.clientIpTrust === undefined ||
|
|
3505
|
+
(platform as { [NIFRA_PLATFORM_CLIENT_IP_DERIVED]?: unknown } | undefined)?.[
|
|
3506
|
+
NIFRA_PLATFORM_CLIENT_IP_DERIVED
|
|
3507
|
+
] === true
|
|
3508
|
+
? platform
|
|
3509
|
+
: this.deriveClientIp(source, platform)
|
|
3510
|
+
}
|
|
3511
|
+
|
|
2676
3512
|
private dispatch<T>(
|
|
2677
3513
|
source: RequestSource,
|
|
2678
|
-
platform: Platform | undefined,
|
|
3514
|
+
platform: Platform<EnvOf<Ctx>> | undefined,
|
|
2679
3515
|
finalize: (result: unknown, set: CtxSet) => T,
|
|
2680
3516
|
wrapResponse: (response: Response | ResponseResult) => T,
|
|
2681
3517
|
onTimeout: () => T,
|
|
@@ -2686,11 +3522,14 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
2686
3522
|
// Resolve the trust declaration into the platform's `clientIp` ONCE, here at the shared funnel, so
|
|
2687
3523
|
// `c.clientIp` (and every hook/derive downstream) sees the derived caller. No config ⇒ the raw
|
|
2688
3524
|
// socket peer the adapter supplied passes through untouched (a one-property no-op on the hot path).
|
|
2689
|
-
|
|
2690
|
-
|
|
3525
|
+
// A platform an enclosing nifra server already resolved (an in-process call from an SSR loader)
|
|
3526
|
+
// keeps its `clientIp`: the synthesized request carries none of the visitor's forwarding headers.
|
|
3527
|
+
const resolved = this.trustedPlatform(source, platform)
|
|
2691
3528
|
// onRequest hooks may be async, so a hooked app takes the async path; with no hooks (the common
|
|
2692
3529
|
// case) routing stays synchronous, letting a bare route resolve with no lifecycle promise at all.
|
|
2693
|
-
|
|
3530
|
+
// A token no route can be registered under skips the hooks as well: it cannot match, so routing
|
|
3531
|
+
// answers it 404 or 405 and no hook reads a method string it would misjudge.
|
|
3532
|
+
if (this.onRequestHooks.length === 0 || !isRoutableMethod(source.method)) {
|
|
2694
3533
|
return this.routeAndRun(source, resolved, finalize, wrapResponse, onTimeout, webFast)
|
|
2695
3534
|
}
|
|
2696
3535
|
if (!webFast && this.canUseNodeRequestHooks()) {
|
|
@@ -2703,7 +3542,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
2703
3542
|
* Web `Request`; arbitrary request rewrites and full Web hooks use {@link runWithOnRequest}. */
|
|
2704
3543
|
private runWithNodeRequest<T>(
|
|
2705
3544
|
source: RequestSource,
|
|
2706
|
-
platform: Platform | undefined,
|
|
3545
|
+
platform: Platform<EnvOf<Ctx>> | undefined,
|
|
2707
3546
|
finalize: (result: unknown, set: CtxSet) => T,
|
|
2708
3547
|
wrapResponse: (response: Response | ResponseResult) => T,
|
|
2709
3548
|
onTimeout: () => T,
|
|
@@ -2737,7 +3576,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
2737
3576
|
nextIndex: number,
|
|
2738
3577
|
request: NodeRequestContext,
|
|
2739
3578
|
source: RequestSource,
|
|
2740
|
-
platform: Platform | undefined,
|
|
3579
|
+
platform: Platform<EnvOf<Ctx>> | undefined,
|
|
2741
3580
|
finalize: (result: unknown, set: CtxSet) => T,
|
|
2742
3581
|
wrapResponse: (response: Response | ResponseResult) => T,
|
|
2743
3582
|
onTimeout: () => T,
|
|
@@ -2765,7 +3604,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
2765
3604
|
*/
|
|
2766
3605
|
private runWithOnRequest<T>(
|
|
2767
3606
|
source: RequestSource,
|
|
2768
|
-
platform: Platform | undefined,
|
|
3607
|
+
platform: Platform<EnvOf<Ctx>> | undefined,
|
|
2769
3608
|
finalize: (result: unknown, set: CtxSet) => T,
|
|
2770
3609
|
wrapResponse: (response: Response | ResponseResult) => T,
|
|
2771
3610
|
onTimeout: () => T,
|
|
@@ -2779,7 +3618,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
2779
3618
|
if (source !== originalRequest) this.responseSources.set(source as object, originalRequest)
|
|
2780
3619
|
let current: RequestSource = source
|
|
2781
3620
|
for (let i = 0; i < hooks.length; i++) {
|
|
2782
|
-
const
|
|
3621
|
+
const seen = requestOf(current)
|
|
3622
|
+
const outcome = (hooks[i] as RawOnRequest)(seen, platform)
|
|
2783
3623
|
if (outcome instanceof Promise) {
|
|
2784
3624
|
return outcome.then((early) =>
|
|
2785
3625
|
this.continueOnRequest(
|
|
@@ -2796,6 +3636,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
2796
3636
|
)
|
|
2797
3637
|
}
|
|
2798
3638
|
if (outcome instanceof Request) {
|
|
3639
|
+
if (outcome !== seen) recordRequestReplacement(outcome, seen)
|
|
2799
3640
|
current = outcome
|
|
2800
3641
|
if (outcome !== originalRequest) this.responseRequests.set(originalRequest, outcome)
|
|
2801
3642
|
continue
|
|
@@ -2812,24 +3653,27 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
2812
3653
|
nextIndex: number,
|
|
2813
3654
|
originalRequest: Request,
|
|
2814
3655
|
sourceAtAwait: RequestSource,
|
|
2815
|
-
platform: Platform | undefined,
|
|
3656
|
+
platform: Platform<EnvOf<Ctx>> | undefined,
|
|
2816
3657
|
finalize: (result: unknown, set: CtxSet) => T,
|
|
2817
3658
|
wrapResponse: (response: Response | ResponseResult) => T,
|
|
2818
3659
|
onTimeout: () => T,
|
|
2819
3660
|
webFast: boolean,
|
|
2820
3661
|
): Promise<T> {
|
|
2821
3662
|
let current = sourceAtAwait
|
|
3663
|
+
let seen = requestOf(sourceAtAwait)
|
|
2822
3664
|
let early = first
|
|
2823
3665
|
let index = nextIndex
|
|
2824
3666
|
for (;;) {
|
|
2825
3667
|
if (early instanceof Request) {
|
|
3668
|
+
if (early !== seen) recordRequestReplacement(early, seen)
|
|
2826
3669
|
current = early
|
|
2827
3670
|
if (early !== originalRequest) this.responseRequests.set(originalRequest, early)
|
|
2828
3671
|
} else if (early !== undefined) {
|
|
2829
3672
|
return wrapResponse(early)
|
|
2830
3673
|
}
|
|
2831
3674
|
if (index >= this.onRequestHooks.length) break
|
|
2832
|
-
|
|
3675
|
+
seen = requestOf(current)
|
|
3676
|
+
const outcome = (this.onRequestHooks[index] as RawOnRequest)(seen, platform)
|
|
2833
3677
|
early = outcome instanceof Promise ? await outcome : outcome
|
|
2834
3678
|
index++
|
|
2835
3679
|
}
|
|
@@ -2906,7 +3750,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
2906
3750
|
*/
|
|
2907
3751
|
private routeAndRun<T>(
|
|
2908
3752
|
source: RequestSource,
|
|
2909
|
-
platform: Platform | undefined,
|
|
3753
|
+
platform: Platform<EnvOf<Ctx>> | undefined,
|
|
2910
3754
|
finalize: (result: unknown, set: CtxSet) => T,
|
|
2911
3755
|
wrapResponse: (response: Response | ResponseResult) => T,
|
|
2912
3756
|
onTimeout: () => T,
|
|
@@ -2950,9 +3794,21 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
2950
3794
|
search = searchStart === -1 ? "" : rawUrl.slice(searchStart, searchEnd)
|
|
2951
3795
|
}
|
|
2952
3796
|
}
|
|
3797
|
+
// Explicit composed mounts run before the parent's route table. This is what lets an SSR shell
|
|
3798
|
+
// mount an API at `/api` without its catch-all page route swallowing the request. Legacy
|
|
3799
|
+
// `mountFetch()` stays behind typed routes and is checked in the existing branch below.
|
|
3800
|
+
if (this.preRouteMountCount > 0) {
|
|
3801
|
+
const mounted = this.fetchMount(pathname, source, platform, true)
|
|
3802
|
+
if (mounted !== undefined) {
|
|
3803
|
+
return mounted instanceof Promise
|
|
3804
|
+
? mounted.then((response) => wrapResponse(response))
|
|
3805
|
+
: wrapResponse(mounted)
|
|
3806
|
+
}
|
|
3807
|
+
}
|
|
3808
|
+
|
|
2953
3809
|
const match = this.catalog.find(source.method, pathname)
|
|
2954
3810
|
if (!match.found) {
|
|
2955
|
-
const mounted = this.fetchMount(pathname, source, platform)
|
|
3811
|
+
const mounted = this.fetchMount(pathname, source, platform, false)
|
|
2956
3812
|
if (mounted !== undefined) {
|
|
2957
3813
|
return mounted instanceof Promise
|
|
2958
3814
|
? mounted.then((response) => wrapResponse(response))
|
|
@@ -2965,7 +3821,10 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
2965
3821
|
plainError(405, "method_not_allowed", { allow: match.allowed.join(", ") }),
|
|
2966
3822
|
)
|
|
2967
3823
|
}
|
|
2968
|
-
|
|
3824
|
+
const lane = this.notFoundLane
|
|
3825
|
+
return lane === undefined
|
|
3826
|
+
? wrapResponse(plainError(404, "not_found"))
|
|
3827
|
+
: lane(this, source, pathname, platform, wrapResponse, onTimeout)
|
|
2969
3828
|
}
|
|
2970
3829
|
|
|
2971
3830
|
// Inspect only captured values for escapes. Scanning the full pathname repeated work the router
|
|
@@ -2991,17 +3850,48 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
2991
3850
|
private fetchMount(
|
|
2992
3851
|
pathname: string,
|
|
2993
3852
|
source: RequestSource,
|
|
2994
|
-
platform: Platform | undefined,
|
|
3853
|
+
platform: Platform<EnvOf<Ctx>> | undefined,
|
|
3854
|
+
beforeRoutes: boolean,
|
|
2995
3855
|
): MaybePromise<Response> | undefined {
|
|
2996
|
-
|
|
2997
|
-
|
|
2998
|
-
|
|
2999
|
-
|
|
3000
|
-
|
|
3001
|
-
|
|
3002
|
-
|
|
3856
|
+
const request = requestOf(source)
|
|
3857
|
+
let first = -1
|
|
3858
|
+
for (let i = 0; i < this.fetchMounts.length; i++) {
|
|
3859
|
+
const mount = this.fetchMounts[i]!
|
|
3860
|
+
if (mount.beforeRoutes !== beforeRoutes || !underMountPrefix(pathname, mount.path)) continue
|
|
3861
|
+
first = i
|
|
3862
|
+
break
|
|
3003
3863
|
}
|
|
3004
|
-
|
|
3864
|
+
// A mounted handler is arbitrary code that may read the method loosely, so it is only handed a
|
|
3865
|
+
// token a route could be registered under; anything else is left to the route table to refuse.
|
|
3866
|
+
if (first === -1 || !isRoutableMethod(source.method)) return undefined
|
|
3867
|
+
|
|
3868
|
+
// A request body is a one-shot stream. Only methods whose request semantics are replayable are
|
|
3869
|
+
// eligible for 404 fallthrough; this intentionally refuses POST/PUT/PATCH even when the body is
|
|
3870
|
+
// empty because an adapter may expose a delayed stream or a protocol-specific side effect.
|
|
3871
|
+
const replayable =
|
|
3872
|
+
source.method === "GET" || source.method === "HEAD" || source.method === "OPTIONS"
|
|
3873
|
+
const dispatch = (index: number, fallback?: Response): MaybePromise<Response> => {
|
|
3874
|
+
let mount: FetchMount<EnvOf<Ctx>> | undefined
|
|
3875
|
+
for (let i = index; i < this.fetchMounts.length; i++) {
|
|
3876
|
+
const candidate = this.fetchMounts[i]!
|
|
3877
|
+
if (candidate.beforeRoutes === beforeRoutes && underMountPrefix(pathname, candidate.path)) {
|
|
3878
|
+
mount = candidate
|
|
3879
|
+
index = i
|
|
3880
|
+
break
|
|
3881
|
+
}
|
|
3882
|
+
}
|
|
3883
|
+
if (mount === undefined) return fallback ?? jsonError(404, "not_found")
|
|
3884
|
+
const childRequest = mount.stripPrefix ? stripMountPrefix(request, mount.path) : request
|
|
3885
|
+
const outcome = mount.handler(childRequest, platform)
|
|
3886
|
+
if (!replayable || !mount.fallbackOn404) return outcome
|
|
3887
|
+
if (outcome instanceof Promise) {
|
|
3888
|
+
return outcome.then((response) =>
|
|
3889
|
+
response.status === 404 ? dispatch(index + 1, response) : response,
|
|
3890
|
+
)
|
|
3891
|
+
}
|
|
3892
|
+
return outcome.status === 404 ? dispatch(index + 1, outcome) : outcome
|
|
3893
|
+
}
|
|
3894
|
+
return dispatch(first)
|
|
3005
3895
|
}
|
|
3006
3896
|
|
|
3007
3897
|
/** Run a route that has already been matched by the runtime or Nifra's portable router. */
|
|
@@ -3025,8 +3915,12 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
3025
3915
|
// handler can reach `c.req`; that lane already enforces `entry.bodyLimit`. Installing the full
|
|
3026
3916
|
// direct-reader cap on `c.req` here would allocate bound readers, closures, and a stream wrapper
|
|
3027
3917
|
// for a body that is already consumed. Keep the lazy transport cap for raw-body routes, where a
|
|
3028
|
-
// user read is the only framework-owned body boundary
|
|
3029
|
-
|
|
3918
|
+
// user read is the only framework-owned body boundary, and for the auth-first lane, whose
|
|
3919
|
+
// derive and beforeHandle hooks reach `c.req` before the body is read.
|
|
3920
|
+
if (
|
|
3921
|
+
entry.bodyLimit !== undefined &&
|
|
3922
|
+
(entry.schema?.body === undefined || entry.program.authBeforeValidation)
|
|
3923
|
+
) {
|
|
3030
3924
|
const method = source.method
|
|
3031
3925
|
if (method !== "GET" && method !== "HEAD") markTransportCap(source, entry.bodyLimit)
|
|
3032
3926
|
}
|
|
@@ -3540,6 +4434,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
3540
4434
|
finish,
|
|
3541
4435
|
wrapResponse,
|
|
3542
4436
|
(err) => logError(err, ctx, finalize, wrapResponse),
|
|
4437
|
+
bodySchema,
|
|
3543
4438
|
)
|
|
3544
4439
|
}
|
|
3545
4440
|
}
|
|
@@ -4109,10 +5004,442 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
4109
5004
|
onParsed,
|
|
4110
5005
|
(response) => this.wrapWebResponse(response),
|
|
4111
5006
|
(err) => Promise.resolve(logError(err, ctx)),
|
|
5007
|
+
bodySchema,
|
|
4112
5008
|
) as MaybePromise<Response>
|
|
4113
5009
|
}
|
|
4114
5010
|
}
|
|
4115
5011
|
|
|
5012
|
+
/**
|
|
5013
|
+
* Generic (finalize/wrapResponse-parameterized) fused runner for the derive-before(-after) lane:
|
|
5014
|
+
* the Node-direct counterpart of `buildFusedDeriveBefore` / `buildFusedDeriveBeforeAfter`.
|
|
5015
|
+
*
|
|
5016
|
+
* The Web fused builders above return a `Response`, so the Node dispatcher (`webFast === false`)
|
|
5017
|
+
* cannot use them and a derive+before route falls back to the generic route program there - two
|
|
5018
|
+
* staged loops, per-stage dispatch, and the async-continuation machinery - while Bun rides the
|
|
5019
|
+
* single closure. That asymmetry is the realistic-route Node deficit against Fastify: this runner
|
|
5020
|
+
* closes it by running validate → derive → before → handler → (after) in one frame and finalizing
|
|
5021
|
+
* through the caller's own `finalize` (the Node outcome on the direct lane, `toResponse` on Web),
|
|
5022
|
+
* so the same registration feeds both runtimes with no per-lane copy of the lifecycle to drift.
|
|
5023
|
+
*
|
|
5024
|
+
* Semantics mirror the generic program stage-for-stage for the shapes this lane admits (exactly
|
|
5025
|
+
* one derive, one beforeHandle, zero-or-one afterHandle, no onError/around/decorations/contracts/
|
|
5026
|
+
* recovery hooks - enforced by `selectRouteLanes`): thrown `Response`/`status(...)` are control
|
|
5027
|
+
* flow through `wrapResponse`/`finalize`, anything else logs + 500s via the shared `bareError`
|
|
5028
|
+
* lane (identical to `handleLifecycleError` with no `onError` hooks), and a terminal `finalize`
|
|
5029
|
+
* throw (e.g. an unstringifiable handler return on the Node lane) is caught exactly as
|
|
5030
|
+
* `finishLifecycleContract` catches it. Step functions close over registration state only and take
|
|
5031
|
+
* the request state as arguments, so the hot path allocates no per-request closures.
|
|
5032
|
+
*/
|
|
5033
|
+
private buildFusedDeriveBeforeRunner(
|
|
5034
|
+
handler: InternalHandler,
|
|
5035
|
+
derive: RawDerive,
|
|
5036
|
+
before: RawBeforeHandle,
|
|
5037
|
+
after: RawAfterHandle | undefined,
|
|
5038
|
+
querySchema: StandardSchemaV1 | undefined,
|
|
5039
|
+
maxBodyBytes: number,
|
|
5040
|
+
): FusedBodyRunner {
|
|
5041
|
+
const logError = <T>(
|
|
5042
|
+
err: unknown,
|
|
5043
|
+
ctx: RawContext,
|
|
5044
|
+
finalize: (result: unknown, set: CtxSet, ctx: RawContext) => T,
|
|
5045
|
+
wrapResponse: (response: Response | ResponseResult) => T,
|
|
5046
|
+
): T => this.bareError(err, ctx, finalize, wrapResponse)
|
|
5047
|
+
|
|
5048
|
+
const runFinish = <T>(
|
|
5049
|
+
result: unknown,
|
|
5050
|
+
ctx: RawContext,
|
|
5051
|
+
finalize: (result: unknown, set: CtxSet, ctx: RawContext) => T,
|
|
5052
|
+
wrapResponse: (response: Response | ResponseResult) => T,
|
|
5053
|
+
): MaybePromise<T> => {
|
|
5054
|
+
if (after === undefined) {
|
|
5055
|
+
try {
|
|
5056
|
+
return finalize(result, responseSet(ctx), ctx)
|
|
5057
|
+
} catch (err) {
|
|
5058
|
+
return logError(err, ctx, finalize, wrapResponse)
|
|
5059
|
+
}
|
|
5060
|
+
}
|
|
5061
|
+
let transformed: unknown
|
|
5062
|
+
try {
|
|
5063
|
+
transformed = after(result, ctx)
|
|
5064
|
+
} catch (err) {
|
|
5065
|
+
return logError(err, ctx, finalize, wrapResponse)
|
|
5066
|
+
}
|
|
5067
|
+
if (transformed instanceof Promise) {
|
|
5068
|
+
return transformed.then(
|
|
5069
|
+
(value) => {
|
|
5070
|
+
try {
|
|
5071
|
+
return finalize(value, responseSet(ctx), ctx)
|
|
5072
|
+
} catch (err) {
|
|
5073
|
+
return logError(err, ctx, finalize, wrapResponse)
|
|
5074
|
+
}
|
|
5075
|
+
},
|
|
5076
|
+
(err) => logError(err, ctx, finalize, wrapResponse),
|
|
5077
|
+
)
|
|
5078
|
+
}
|
|
5079
|
+
try {
|
|
5080
|
+
return finalize(transformed, responseSet(ctx), ctx)
|
|
5081
|
+
} catch (err) {
|
|
5082
|
+
return logError(err, ctx, finalize, wrapResponse)
|
|
5083
|
+
}
|
|
5084
|
+
}
|
|
5085
|
+
|
|
5086
|
+
const finalizeEarly = <T>(
|
|
5087
|
+
result: unknown,
|
|
5088
|
+
ctx: RawContext,
|
|
5089
|
+
finalize: (result: unknown, set: CtxSet, ctx: RawContext) => T,
|
|
5090
|
+
wrapResponse: (response: Response | ResponseResult) => T,
|
|
5091
|
+
): T => {
|
|
5092
|
+
try {
|
|
5093
|
+
return finalize(result, responseSet(ctx), ctx)
|
|
5094
|
+
} catch (err) {
|
|
5095
|
+
return logError(err, ctx, finalize, wrapResponse)
|
|
5096
|
+
}
|
|
5097
|
+
}
|
|
5098
|
+
|
|
5099
|
+
const runHandler = <T>(
|
|
5100
|
+
ctx: RawContext,
|
|
5101
|
+
finalize: (result: unknown, set: CtxSet, ctx: RawContext) => T,
|
|
5102
|
+
wrapResponse: (response: Response | ResponseResult) => T,
|
|
5103
|
+
): MaybePromise<T> => {
|
|
5104
|
+
let result: unknown
|
|
5105
|
+
try {
|
|
5106
|
+
result = handler(ctx)
|
|
5107
|
+
} catch (err) {
|
|
5108
|
+
return logError(err, ctx, finalize, wrapResponse)
|
|
5109
|
+
}
|
|
5110
|
+
if (result instanceof Promise) {
|
|
5111
|
+
return result.then(
|
|
5112
|
+
(value) => runFinish(value, ctx, finalize, wrapResponse),
|
|
5113
|
+
(err) => logError(err, ctx, finalize, wrapResponse),
|
|
5114
|
+
)
|
|
5115
|
+
}
|
|
5116
|
+
return runFinish(result, ctx, finalize, wrapResponse)
|
|
5117
|
+
}
|
|
5118
|
+
|
|
5119
|
+
const runBefore = <T>(
|
|
5120
|
+
ctx: RawContext,
|
|
5121
|
+
finalize: (result: unknown, set: CtxSet, ctx: RawContext) => T,
|
|
5122
|
+
wrapResponse: (response: Response | ResponseResult) => T,
|
|
5123
|
+
): MaybePromise<T> => {
|
|
5124
|
+
let early: unknown
|
|
5125
|
+
try {
|
|
5126
|
+
early = before(ctx)
|
|
5127
|
+
} catch (err) {
|
|
5128
|
+
return logError(err, ctx, finalize, wrapResponse)
|
|
5129
|
+
}
|
|
5130
|
+
if (early instanceof Promise) {
|
|
5131
|
+
return early.then(
|
|
5132
|
+
(settled) =>
|
|
5133
|
+
settled === undefined
|
|
5134
|
+
? runHandler(ctx, finalize, wrapResponse)
|
|
5135
|
+
: finalizeEarly(settled, ctx, finalize, wrapResponse),
|
|
5136
|
+
(err) => logError(err, ctx, finalize, wrapResponse),
|
|
5137
|
+
)
|
|
5138
|
+
}
|
|
5139
|
+
if (early === undefined) return runHandler(ctx, finalize, wrapResponse)
|
|
5140
|
+
return finalizeEarly(early, ctx, finalize, wrapResponse)
|
|
5141
|
+
}
|
|
5142
|
+
|
|
5143
|
+
const runDerive = <T>(
|
|
5144
|
+
ctx: RawContext,
|
|
5145
|
+
finalize: (result: unknown, set: CtxSet, ctx: RawContext) => T,
|
|
5146
|
+
wrapResponse: (response: Response | ResponseResult) => T,
|
|
5147
|
+
): MaybePromise<T> => {
|
|
5148
|
+
let derived: unknown
|
|
5149
|
+
try {
|
|
5150
|
+
derived = derive(ctx)
|
|
5151
|
+
} catch (err) {
|
|
5152
|
+
return logError(err, ctx, finalize, wrapResponse)
|
|
5153
|
+
}
|
|
5154
|
+
if (derived instanceof Promise) {
|
|
5155
|
+
return derived.then(
|
|
5156
|
+
(settled) => {
|
|
5157
|
+
if (isResponseResult(settled) || settled instanceof Response) {
|
|
5158
|
+
return finalizeEarly(settled, ctx, finalize, wrapResponse)
|
|
5159
|
+
}
|
|
5160
|
+
Object.assign(ctx, settled as object)
|
|
5161
|
+
return runBefore(ctx, finalize, wrapResponse)
|
|
5162
|
+
},
|
|
5163
|
+
(err) => logError(err, ctx, finalize, wrapResponse),
|
|
5164
|
+
)
|
|
5165
|
+
}
|
|
5166
|
+
if (isResponseResult(derived) || derived instanceof Response) {
|
|
5167
|
+
return finalizeEarly(derived, ctx, finalize, wrapResponse)
|
|
5168
|
+
}
|
|
5169
|
+
Object.assign(ctx, derived as object)
|
|
5170
|
+
return runBefore(ctx, finalize, wrapResponse)
|
|
5171
|
+
}
|
|
5172
|
+
|
|
5173
|
+
const runValidated = <T>(
|
|
5174
|
+
validation: StandardResult<unknown> | Promise<StandardResult<unknown>>,
|
|
5175
|
+
ctx: RawContext,
|
|
5176
|
+
finalize: (result: unknown, set: CtxSet, ctx: RawContext) => T,
|
|
5177
|
+
wrapResponse: (response: Response | ResponseResult) => T,
|
|
5178
|
+
): MaybePromise<T> => {
|
|
5179
|
+
if (validation instanceof Promise) {
|
|
5180
|
+
return validation.then(
|
|
5181
|
+
(settled) => {
|
|
5182
|
+
if (settled.issues !== undefined)
|
|
5183
|
+
return wrapResponse(plainValidationError(settled.issues))
|
|
5184
|
+
ctx.query = settled.value
|
|
5185
|
+
return runDerive(ctx, finalize, wrapResponse)
|
|
5186
|
+
},
|
|
5187
|
+
(err) => logError(err, ctx, finalize, wrapResponse),
|
|
5188
|
+
)
|
|
5189
|
+
}
|
|
5190
|
+
if (validation.issues !== undefined)
|
|
5191
|
+
return wrapResponse(plainValidationError(validation.issues))
|
|
5192
|
+
ctx.query = validation.value
|
|
5193
|
+
return runDerive(ctx, finalize, wrapResponse)
|
|
5194
|
+
}
|
|
5195
|
+
|
|
5196
|
+
return <T>(
|
|
5197
|
+
source: RequestSource,
|
|
5198
|
+
params: Record<string, string>,
|
|
5199
|
+
search: string | undefined,
|
|
5200
|
+
signal: AbortSignal,
|
|
5201
|
+
budget: RequestBudget,
|
|
5202
|
+
platform: Platform | undefined,
|
|
5203
|
+
nativeContext: boolean,
|
|
5204
|
+
finalize: (result: unknown, set: CtxSet, ctx: RawContext) => T,
|
|
5205
|
+
wrapResponse: (response: Response | ResponseResult) => T,
|
|
5206
|
+
): MaybePromise<T> => {
|
|
5207
|
+
const ctx = nativeContext
|
|
5208
|
+
? RequestContext.native(source, params, search, maxBodyBytes, platform, this.protoPoisoning)
|
|
5209
|
+
: new RequestContext(
|
|
5210
|
+
source,
|
|
5211
|
+
params,
|
|
5212
|
+
search,
|
|
5213
|
+
signal,
|
|
5214
|
+
budget,
|
|
5215
|
+
platform,
|
|
5216
|
+
maxBodyBytes,
|
|
5217
|
+
this.protoPoisoning,
|
|
5218
|
+
)
|
|
5219
|
+
if (querySchema === undefined) return runDerive(ctx, finalize, wrapResponse)
|
|
5220
|
+
let validation: StandardResult<unknown> | Promise<StandardResult<unknown>>
|
|
5221
|
+
try {
|
|
5222
|
+
validation = querySchema["~standard"].validate(queryObjectOf(ctx[CONTEXT_SEARCH]))
|
|
5223
|
+
} catch (err) {
|
|
5224
|
+
return logError(err, ctx, finalize, wrapResponse)
|
|
5225
|
+
}
|
|
5226
|
+
return runValidated(validation, ctx, finalize, wrapResponse)
|
|
5227
|
+
}
|
|
5228
|
+
}
|
|
5229
|
+
|
|
5230
|
+
/**
|
|
5231
|
+
* Generic fused runner for the body+derive-before(-after) lane: the Node-direct counterpart of
|
|
5232
|
+
* `buildFusedBodyDeriveBeforeAfter`. Same contract as `buildFusedDeriveBeforeRunner` above - one
|
|
5233
|
+
* frame running parse → validate → derive → before → handler → (after) through the caller's own
|
|
5234
|
+
* `finalize` - with the body read ahead of validation, exactly as the generic program orders its
|
|
5235
|
+
* body stage before the derive/before stages. `readBodyFramed` keeps the shared bounded-read,
|
|
5236
|
+
* content-type, and prototype-poisoning contracts; the 415/400 renders take `wrapResponse`
|
|
5237
|
+
* directly, which is what the generic lane's wrap-after-identity amounts to.
|
|
5238
|
+
*/
|
|
5239
|
+
private buildFusedBodyDeriveBeforeAfterRunner(
|
|
5240
|
+
handler: InternalHandler,
|
|
5241
|
+
derive: RawDerive,
|
|
5242
|
+
before: RawBeforeHandle,
|
|
5243
|
+
after: RawAfterHandle | undefined,
|
|
5244
|
+
bodySchema: StandardSchemaV1,
|
|
5245
|
+
maxBodyBytes: number,
|
|
5246
|
+
): FusedBodyRunner {
|
|
5247
|
+
const logError = <T>(
|
|
5248
|
+
err: unknown,
|
|
5249
|
+
ctx: RawContext,
|
|
5250
|
+
finalize: (result: unknown, set: CtxSet, ctx: RawContext) => T,
|
|
5251
|
+
wrapResponse: (response: Response | ResponseResult) => T,
|
|
5252
|
+
): T => this.bareError(err, ctx, finalize, wrapResponse)
|
|
5253
|
+
|
|
5254
|
+
const runFinish = <T>(
|
|
5255
|
+
result: unknown,
|
|
5256
|
+
ctx: RawContext,
|
|
5257
|
+
finalize: (result: unknown, set: CtxSet, ctx: RawContext) => T,
|
|
5258
|
+
wrapResponse: (response: Response | ResponseResult) => T,
|
|
5259
|
+
): MaybePromise<T> => {
|
|
5260
|
+
if (after === undefined) {
|
|
5261
|
+
try {
|
|
5262
|
+
return finalize(result, responseSet(ctx), ctx)
|
|
5263
|
+
} catch (err) {
|
|
5264
|
+
return logError(err, ctx, finalize, wrapResponse)
|
|
5265
|
+
}
|
|
5266
|
+
}
|
|
5267
|
+
let transformed: unknown
|
|
5268
|
+
try {
|
|
5269
|
+
transformed = after(result, ctx)
|
|
5270
|
+
} catch (err) {
|
|
5271
|
+
return logError(err, ctx, finalize, wrapResponse)
|
|
5272
|
+
}
|
|
5273
|
+
if (transformed instanceof Promise) {
|
|
5274
|
+
return transformed.then(
|
|
5275
|
+
(value) => {
|
|
5276
|
+
try {
|
|
5277
|
+
return finalize(value, responseSet(ctx), ctx)
|
|
5278
|
+
} catch (err) {
|
|
5279
|
+
return logError(err, ctx, finalize, wrapResponse)
|
|
5280
|
+
}
|
|
5281
|
+
},
|
|
5282
|
+
(err) => logError(err, ctx, finalize, wrapResponse),
|
|
5283
|
+
)
|
|
5284
|
+
}
|
|
5285
|
+
try {
|
|
5286
|
+
return finalize(transformed, responseSet(ctx), ctx)
|
|
5287
|
+
} catch (err) {
|
|
5288
|
+
return logError(err, ctx, finalize, wrapResponse)
|
|
5289
|
+
}
|
|
5290
|
+
}
|
|
5291
|
+
|
|
5292
|
+
const finalizeEarly = <T>(
|
|
5293
|
+
result: unknown,
|
|
5294
|
+
ctx: RawContext,
|
|
5295
|
+
finalize: (result: unknown, set: CtxSet, ctx: RawContext) => T,
|
|
5296
|
+
wrapResponse: (response: Response | ResponseResult) => T,
|
|
5297
|
+
): T => {
|
|
5298
|
+
try {
|
|
5299
|
+
return finalize(result, responseSet(ctx), ctx)
|
|
5300
|
+
} catch (err) {
|
|
5301
|
+
return logError(err, ctx, finalize, wrapResponse)
|
|
5302
|
+
}
|
|
5303
|
+
}
|
|
5304
|
+
|
|
5305
|
+
const runHandler = <T>(
|
|
5306
|
+
ctx: RawContext,
|
|
5307
|
+
finalize: (result: unknown, set: CtxSet, ctx: RawContext) => T,
|
|
5308
|
+
wrapResponse: (response: Response | ResponseResult) => T,
|
|
5309
|
+
): MaybePromise<T> => {
|
|
5310
|
+
let result: unknown
|
|
5311
|
+
try {
|
|
5312
|
+
result = handler(ctx)
|
|
5313
|
+
} catch (err) {
|
|
5314
|
+
return logError(err, ctx, finalize, wrapResponse)
|
|
5315
|
+
}
|
|
5316
|
+
if (result instanceof Promise) {
|
|
5317
|
+
return result.then(
|
|
5318
|
+
(value) => runFinish(value, ctx, finalize, wrapResponse),
|
|
5319
|
+
(err) => logError(err, ctx, finalize, wrapResponse),
|
|
5320
|
+
)
|
|
5321
|
+
}
|
|
5322
|
+
return runFinish(result, ctx, finalize, wrapResponse)
|
|
5323
|
+
}
|
|
5324
|
+
|
|
5325
|
+
const runBefore = <T>(
|
|
5326
|
+
ctx: RawContext,
|
|
5327
|
+
finalize: (result: unknown, set: CtxSet, ctx: RawContext) => T,
|
|
5328
|
+
wrapResponse: (response: Response | ResponseResult) => T,
|
|
5329
|
+
): MaybePromise<T> => {
|
|
5330
|
+
let early: unknown
|
|
5331
|
+
try {
|
|
5332
|
+
early = before(ctx)
|
|
5333
|
+
} catch (err) {
|
|
5334
|
+
return logError(err, ctx, finalize, wrapResponse)
|
|
5335
|
+
}
|
|
5336
|
+
if (early instanceof Promise) {
|
|
5337
|
+
return early.then(
|
|
5338
|
+
(settled) =>
|
|
5339
|
+
settled === undefined
|
|
5340
|
+
? runHandler(ctx, finalize, wrapResponse)
|
|
5341
|
+
: finalizeEarly(settled, ctx, finalize, wrapResponse),
|
|
5342
|
+
(err) => logError(err, ctx, finalize, wrapResponse),
|
|
5343
|
+
)
|
|
5344
|
+
}
|
|
5345
|
+
if (early === undefined) return runHandler(ctx, finalize, wrapResponse)
|
|
5346
|
+
return finalizeEarly(early, ctx, finalize, wrapResponse)
|
|
5347
|
+
}
|
|
5348
|
+
|
|
5349
|
+
const runDerive = <T>(
|
|
5350
|
+
ctx: RawContext,
|
|
5351
|
+
finalize: (result: unknown, set: CtxSet, ctx: RawContext) => T,
|
|
5352
|
+
wrapResponse: (response: Response | ResponseResult) => T,
|
|
5353
|
+
): MaybePromise<T> => {
|
|
5354
|
+
let derived: unknown
|
|
5355
|
+
try {
|
|
5356
|
+
derived = derive(ctx)
|
|
5357
|
+
} catch (err) {
|
|
5358
|
+
return logError(err, ctx, finalize, wrapResponse)
|
|
5359
|
+
}
|
|
5360
|
+
if (derived instanceof Promise) {
|
|
5361
|
+
return derived.then(
|
|
5362
|
+
(settled) => {
|
|
5363
|
+
if (isResponseResult(settled) || settled instanceof Response) {
|
|
5364
|
+
return finalizeEarly(settled, ctx, finalize, wrapResponse)
|
|
5365
|
+
}
|
|
5366
|
+
Object.assign(ctx, settled as object)
|
|
5367
|
+
return runBefore(ctx, finalize, wrapResponse)
|
|
5368
|
+
},
|
|
5369
|
+
(err) => logError(err, ctx, finalize, wrapResponse),
|
|
5370
|
+
)
|
|
5371
|
+
}
|
|
5372
|
+
if (isResponseResult(derived) || derived instanceof Response) {
|
|
5373
|
+
return finalizeEarly(derived, ctx, finalize, wrapResponse)
|
|
5374
|
+
}
|
|
5375
|
+
Object.assign(ctx, derived as object)
|
|
5376
|
+
return runBefore(ctx, finalize, wrapResponse)
|
|
5377
|
+
}
|
|
5378
|
+
|
|
5379
|
+
const onParsed = <T>(
|
|
5380
|
+
parsed: unknown,
|
|
5381
|
+
ctx: RawContext,
|
|
5382
|
+
finalize: (result: unknown, set: CtxSet, ctx: RawContext) => T,
|
|
5383
|
+
wrapResponse: (response: Response | ResponseResult) => T,
|
|
5384
|
+
): MaybePromise<T> => {
|
|
5385
|
+
let validation: StandardResult<unknown> | Promise<StandardResult<unknown>>
|
|
5386
|
+
try {
|
|
5387
|
+
validation = bodySchema["~standard"].validate(parsed)
|
|
5388
|
+
} catch (err) {
|
|
5389
|
+
return logError(err, ctx, finalize, wrapResponse)
|
|
5390
|
+
}
|
|
5391
|
+
if (validation instanceof Promise) {
|
|
5392
|
+
return validation.then(
|
|
5393
|
+
(settled) => {
|
|
5394
|
+
if (settled.issues !== undefined)
|
|
5395
|
+
return wrapResponse(plainValidationError(settled.issues))
|
|
5396
|
+
ctx.body = settled.value
|
|
5397
|
+
return runDerive(ctx, finalize, wrapResponse)
|
|
5398
|
+
},
|
|
5399
|
+
(err) => logError(err, ctx, finalize, wrapResponse),
|
|
5400
|
+
)
|
|
5401
|
+
}
|
|
5402
|
+
if (validation.issues !== undefined)
|
|
5403
|
+
return wrapResponse(plainValidationError(validation.issues))
|
|
5404
|
+
ctx.body = validation.value
|
|
5405
|
+
return runDerive(ctx, finalize, wrapResponse)
|
|
5406
|
+
}
|
|
5407
|
+
|
|
5408
|
+
return <T>(
|
|
5409
|
+
source: RequestSource,
|
|
5410
|
+
params: Record<string, string>,
|
|
5411
|
+
search: string | undefined,
|
|
5412
|
+
signal: AbortSignal,
|
|
5413
|
+
budget: RequestBudget,
|
|
5414
|
+
platform: Platform | undefined,
|
|
5415
|
+
nativeContext: boolean,
|
|
5416
|
+
finalize: (result: unknown, set: CtxSet, ctx: RawContext) => T,
|
|
5417
|
+
wrapResponse: (response: Response | ResponseResult) => T,
|
|
5418
|
+
): MaybePromise<T> => {
|
|
5419
|
+
const ctx = nativeContext
|
|
5420
|
+
? RequestContext.native(source, params, search, maxBodyBytes, platform, this.protoPoisoning)
|
|
5421
|
+
: new RequestContext(
|
|
5422
|
+
source,
|
|
5423
|
+
params,
|
|
5424
|
+
search,
|
|
5425
|
+
signal,
|
|
5426
|
+
budget,
|
|
5427
|
+
platform,
|
|
5428
|
+
maxBodyBytes,
|
|
5429
|
+
this.protoPoisoning,
|
|
5430
|
+
)
|
|
5431
|
+
return readBodyFramed(
|
|
5432
|
+
source,
|
|
5433
|
+
maxBodyBytes,
|
|
5434
|
+
this.protoPoisoning,
|
|
5435
|
+
(parsed) => onParsed(parsed, ctx, finalize, wrapResponse),
|
|
5436
|
+
wrapResponse,
|
|
5437
|
+
(err) => logError(err, ctx, finalize, wrapResponse),
|
|
5438
|
+
bodySchema,
|
|
5439
|
+
)
|
|
5440
|
+
}
|
|
5441
|
+
}
|
|
5442
|
+
|
|
4116
5443
|
private bareError<T>(
|
|
4117
5444
|
err: unknown,
|
|
4118
5445
|
ctx: RawContext,
|
|
@@ -4126,7 +5453,6 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
4126
5453
|
err,
|
|
4127
5454
|
ctx,
|
|
4128
5455
|
finalize,
|
|
4129
|
-
wrapResponse,
|
|
4130
5456
|
responseSet,
|
|
4131
5457
|
(e, c) => this.logRequestError(e, c),
|
|
4132
5458
|
() => this.internalErrorResponse(wrapResponse),
|
|
@@ -4176,19 +5502,17 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
4176
5502
|
// @ts-expect-error TS6133 -- invoked structurally by the general route program
|
|
4177
5503
|
private validateProgramStage(
|
|
4178
5504
|
entry: RouteEntry,
|
|
4179
|
-
stage: Extract<RouteProgramStage, { kind:
|
|
5505
|
+
stage: Extract<RouteProgramStage, { kind: ProgramValidationKind }>,
|
|
4180
5506
|
source: RequestSource,
|
|
4181
5507
|
ctx: RawContext,
|
|
4182
5508
|
): MaybePromise<Response | ResponseResult | undefined> {
|
|
5509
|
+
if (stage.kind === "body") return this.readProgramBody(entry, source, ctx)
|
|
4183
5510
|
const input =
|
|
4184
5511
|
stage.kind === "headers"
|
|
4185
5512
|
? headerObjectOf(source.headers)
|
|
4186
|
-
: stage.kind === "
|
|
4187
|
-
? ctx
|
|
4188
|
-
: stage.kind
|
|
4189
|
-
? queryObjectOf(ctx[CONTEXT_SEARCH])
|
|
4190
|
-
: undefined
|
|
4191
|
-
if (stage.kind === "body") return this.readProgramBody(entry, source, ctx)
|
|
5513
|
+
: stage.kind === "query"
|
|
5514
|
+
? queryObjectOf(ctx[CONTEXT_SEARCH])
|
|
5515
|
+
: ctx[stage.kind]
|
|
4192
5516
|
const validation = stage.schema["~standard"].validate(input)
|
|
4193
5517
|
return validation instanceof Promise
|
|
4194
5518
|
? validation.then((result) => this.applyLifecycleValidation(entry, result, ctx, stage.kind))
|
|
@@ -4263,6 +5587,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
4263
5587
|
(parsed) => this.finishBodyOnly(entry, parsed, ctx, finalize, wrapResponse),
|
|
4264
5588
|
wrapResponse,
|
|
4265
5589
|
(err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse),
|
|
5590
|
+
entry.schema?.body,
|
|
4266
5591
|
)
|
|
4267
5592
|
}
|
|
4268
5593
|
|
|
@@ -4627,7 +5952,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
4627
5952
|
try {
|
|
4628
5953
|
const contract = entry.responseContract
|
|
4629
5954
|
if (contract === undefined) return finalize(result, responseSet(ctx))
|
|
4630
|
-
const checked = contract.runtime.check(contract.
|
|
5955
|
+
const checked = contract.runtime.check(contract.definition, result, ctx)
|
|
4631
5956
|
if (checked instanceof Promise) {
|
|
4632
5957
|
return checked.then(
|
|
4633
5958
|
(outcome) => this.finishContractOutcome(ctx, finalize, wrapResponse, outcome),
|
|
@@ -4681,13 +6006,15 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
4681
6006
|
wrapResponse: (response: Response | ResponseResult) => T,
|
|
4682
6007
|
): MaybePromise<T> {
|
|
4683
6008
|
// A *thrown* Response is deliberate control flow, not an error - a guard throws a redirect/401,
|
|
4684
|
-
// an action throws an error page.
|
|
4685
|
-
//
|
|
4686
|
-
//
|
|
4687
|
-
|
|
6009
|
+
// an action throws an error page. Send it as the same Response returned would be sent (Remix/
|
|
6010
|
+
// SvelteKit semantics): as built, plus the cookies the request queued, so a session set or
|
|
6011
|
+
// cleared before `throw redirect(...)` / `requireSession(...)` ships. Don't run onError or log
|
|
6012
|
+
// it as a 500.
|
|
6013
|
+
if (err instanceof Response) return finalize(err, responseSet(ctx))
|
|
4688
6014
|
// Same rule for a thrown `status(...)`, but through `finalize` rather than `wrapResponse`: the
|
|
4689
6015
|
// value is still plain data, so the ordinary JSON lane renders it and no `Response` is built.
|
|
4690
|
-
if (isResponseResult(err))
|
|
6016
|
+
if (isResponseResult(err))
|
|
6017
|
+
return this.finishLifecycleContract(entry, ctx, finalize, wrapResponse, err)
|
|
4691
6018
|
if (entry.onError.length === 0) {
|
|
4692
6019
|
// Never crash the server or leak internals. The client gets a flat 500; the detail goes to the
|
|
4693
6020
|
// (redacting) logger. Body-read failures and around-hook failures land here too.
|
|
@@ -4747,6 +6074,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
4747
6074
|
validate,
|
|
4748
6075
|
(response) => response,
|
|
4749
6076
|
(error) => Promise.reject(error),
|
|
6077
|
+
bodySchema,
|
|
4750
6078
|
)
|
|
4751
6079
|
}
|
|
4752
6080
|
|
|
@@ -4756,13 +6084,10 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
4756
6084
|
entry: RouteEntry,
|
|
4757
6085
|
result: StandardResult<unknown>,
|
|
4758
6086
|
ctx: RawContext,
|
|
4759
|
-
kind:
|
|
6087
|
+
kind: ProgramValidationKind,
|
|
4760
6088
|
): MaybePromise<Response | ResponseResult | undefined> {
|
|
4761
6089
|
const assign = (value: unknown): void => {
|
|
4762
|
-
|
|
4763
|
-
else if (kind === "query") ctx.query = value
|
|
4764
|
-
else if (kind === "headers") ctx.headers = value as Record<string, string>
|
|
4765
|
-
else ctx.params = value as Record<string, string>
|
|
6090
|
+
;(ctx as unknown as Record<ProgramValidationKind, unknown>)[kind] = value
|
|
4766
6091
|
}
|
|
4767
6092
|
if (result.issues === undefined) {
|
|
4768
6093
|
assign(result.value)
|
|
@@ -4781,22 +6106,14 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
4781
6106
|
|
|
4782
6107
|
private finishLifecycleValidationRecovery(
|
|
4783
6108
|
entry: RouteEntry,
|
|
4784
|
-
kind:
|
|
6109
|
+
kind: ProgramValidationKind,
|
|
4785
6110
|
issues: ReadonlyArray<StandardIssue>,
|
|
4786
6111
|
recovery: unknown,
|
|
4787
6112
|
assign: (value: unknown) => void,
|
|
4788
6113
|
): MaybePromise<Response | ResponseResult | undefined> {
|
|
4789
6114
|
if (recovery === undefined) return plainValidationError(issues)
|
|
4790
6115
|
if (recovery instanceof Response) return recovery
|
|
4791
|
-
const
|
|
4792
|
-
kind === "body"
|
|
4793
|
-
? entry.schema?.body
|
|
4794
|
-
: kind === "query"
|
|
4795
|
-
? entry.schema?.query
|
|
4796
|
-
: kind === "params"
|
|
4797
|
-
? entry.schema?.params
|
|
4798
|
-
: entry.schema?.headers
|
|
4799
|
-
const retried = schema!["~standard"].validate(recovery)
|
|
6116
|
+
const retried = entry.schema![kind]!["~standard"].validate(recovery)
|
|
4800
6117
|
if (retried instanceof Promise) {
|
|
4801
6118
|
return retried.then((settled) => {
|
|
4802
6119
|
if (settled.issues !== undefined) return plainValidationError(settled.issues)
|
|
@@ -4817,6 +6134,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
4817
6134
|
fused: FusedWebRunner | undefined,
|
|
4818
6135
|
signal: AbortSignal | undefined,
|
|
4819
6136
|
budget: RequestBudget | undefined,
|
|
6137
|
+
peer: PeerPlatform,
|
|
6138
|
+
fallback: BunNativeHandler,
|
|
4820
6139
|
): BunNativeHandler {
|
|
4821
6140
|
// This callback is reached only from Bun's compiled native route table. Bun has already parsed
|
|
4822
6141
|
// the HTTP framing, so the JSON lane may retain its native fused `json()` parse without weakening
|
|
@@ -4825,7 +6144,10 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
4825
6144
|
// The fused native lane bypasses `runMatched`, so the route's transport byte cap must be
|
|
4826
6145
|
// marked here too - otherwise a Bun `listen()` fused route would leave direct `c.req` body
|
|
4827
6146
|
// reads uncapped. The non-fused branches go through `fetchMatched` -> `runMatched`, which marks.
|
|
4828
|
-
const bodyLimit =
|
|
6147
|
+
const bodyLimit =
|
|
6148
|
+
entry.schema?.body === undefined || entry.program.authBeforeValidation
|
|
6149
|
+
? entry.bodyLimit
|
|
6150
|
+
: undefined
|
|
4829
6151
|
const inner = fused
|
|
4830
6152
|
const capped: FusedWebRunner | undefined =
|
|
4831
6153
|
inner === undefined || bodyLimit === undefined
|
|
@@ -4835,68 +6157,90 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
4835
6157
|
if (method !== "GET" && method !== "HEAD") markTransportCap(source, bodyLimit)
|
|
4836
6158
|
return inner(source, params, search, fusedSignal, fusedBudget, platform, nativeContext)
|
|
4837
6159
|
}
|
|
6160
|
+
const nativeResponseHooks = this.onResponseHooks.length > 0 && this.bunNativeResponseHeadersOnly
|
|
6161
|
+
const finishNative = (request: Request, response: Response): MaybePromise<Response> => {
|
|
6162
|
+
if (!nativeResponseHooks) return response
|
|
6163
|
+
return this.applyOnResponseAndFinalize(response, request)
|
|
6164
|
+
}
|
|
6165
|
+
const runNative = (
|
|
6166
|
+
request: Request,
|
|
6167
|
+
params: Record<string, string>,
|
|
6168
|
+
): MaybePromise<Response> => {
|
|
6169
|
+
const outcome = capped!(request, params, undefined, signal!, budget!, peer, true)
|
|
6170
|
+
return outcome instanceof Promise
|
|
6171
|
+
? outcome.then((response) => finishNative(request, response))
|
|
6172
|
+
: finishNative(request, outcome)
|
|
6173
|
+
}
|
|
4838
6174
|
if (paramNames.length === 0) {
|
|
4839
6175
|
if (capped === undefined) {
|
|
4840
6176
|
return (request) => {
|
|
4841
6177
|
markFramed(request)
|
|
4842
|
-
return this.fetchMatched(request, entry, EMPTY_PARAMS)
|
|
6178
|
+
return this.fetchMatched(request, entry, EMPTY_PARAMS, peer)
|
|
4843
6179
|
}
|
|
4844
6180
|
}
|
|
4845
6181
|
if (this.acceptInboundDeadlines) {
|
|
4846
6182
|
return (request) => {
|
|
4847
6183
|
markFramed(request)
|
|
4848
6184
|
return request.headers.get(NIFRA_DEADLINE_HEADER) !== null
|
|
4849
|
-
? this.fetchMatched(request, entry, EMPTY_PARAMS)
|
|
4850
|
-
: capped(request, EMPTY_PARAMS, undefined, signal!, budget!,
|
|
6185
|
+
? this.fetchMatched(request, entry, EMPTY_PARAMS, peer)
|
|
6186
|
+
: capped(request, EMPTY_PARAMS, undefined, signal!, budget!, peer, true)
|
|
4851
6187
|
}
|
|
4852
6188
|
}
|
|
4853
6189
|
return (request) => {
|
|
4854
6190
|
markFramed(request)
|
|
4855
|
-
return
|
|
6191
|
+
return runNative(request, EMPTY_PARAMS)
|
|
4856
6192
|
}
|
|
4857
6193
|
}
|
|
4858
6194
|
|
|
4859
6195
|
const malformed =
|
|
4860
6196
|
paramNames.length === 1
|
|
4861
|
-
? (params: Record<string, string>) => params[paramNames[0]!]
|
|
4862
|
-
:
|
|
6197
|
+
? (params: Record<string, string>) => unroutedParam(params[paramNames[0]!])
|
|
6198
|
+
: hasUnroutedParam
|
|
4863
6199
|
if (capped === undefined) {
|
|
4864
|
-
return (request) => {
|
|
6200
|
+
return (request, server) => {
|
|
4865
6201
|
markFramed(request)
|
|
4866
6202
|
const params = (request as BunRequestWithParams).params ?? EMPTY_PARAMS
|
|
4867
|
-
if (malformed(params)) return
|
|
4868
|
-
return this.fetchMatched(request, entry, params)
|
|
6203
|
+
if (malformed(params)) return fallback(request, server)
|
|
6204
|
+
return this.fetchMatched(request, entry, params, peer)
|
|
4869
6205
|
}
|
|
4870
6206
|
}
|
|
4871
6207
|
if (this.acceptInboundDeadlines) {
|
|
4872
|
-
return (request) => {
|
|
6208
|
+
return (request, server) => {
|
|
4873
6209
|
markFramed(request)
|
|
4874
6210
|
const params = (request as BunRequestWithParams).params ?? EMPTY_PARAMS
|
|
4875
|
-
if (malformed(params)) return
|
|
6211
|
+
if (malformed(params)) return fallback(request, server)
|
|
4876
6212
|
return request.headers.get(NIFRA_DEADLINE_HEADER) !== null
|
|
4877
|
-
? this.fetchMatched(request, entry, params)
|
|
4878
|
-
: capped(request, params, undefined, signal!, budget!,
|
|
6213
|
+
? this.fetchMatched(request, entry, params, peer)
|
|
6214
|
+
: capped(request, params, undefined, signal!, budget!, peer, true)
|
|
4879
6215
|
}
|
|
4880
6216
|
}
|
|
4881
|
-
return (request) => {
|
|
6217
|
+
return (request, server) => {
|
|
4882
6218
|
markFramed(request)
|
|
4883
6219
|
const params = (request as BunRequestWithParams).params ?? EMPTY_PARAMS
|
|
4884
|
-
if (malformed(params)) return
|
|
4885
|
-
return
|
|
6220
|
+
if (malformed(params)) return fallback(request, server)
|
|
6221
|
+
return runNative(request, params)
|
|
4886
6222
|
}
|
|
4887
6223
|
}
|
|
4888
6224
|
|
|
4889
6225
|
/** Compile portable route registrations into Bun's native route table. Apps with request-rewrite
|
|
4890
6226
|
* hooks or WebSockets retain the single portable dispatcher because those features must run before
|
|
4891
|
-
* route selection/upgrade.
|
|
4892
|
-
*
|
|
4893
|
-
|
|
6227
|
+
* route selection/upgrade.
|
|
6228
|
+
*
|
|
6229
|
+
* Bun and the portable router agree on which of two static-and-`:param` paths wins a request, so
|
|
6230
|
+
* the table is kept to those paths. Where Bun would otherwise pass a request on to a less specific
|
|
6231
|
+
* path, the more specific one carries `fallback`, the portable dispatcher, under that method.
|
|
6232
|
+
* `peer` is the platform every request served from the table shares. */
|
|
6233
|
+
private buildBunNativeRoutes(
|
|
6234
|
+
fallback: BunNativeHandler,
|
|
6235
|
+
peer: PeerPlatform,
|
|
6236
|
+
): BunNativeRoutes | undefined {
|
|
4894
6237
|
// A `clientIp` trust declaration must run the resolver in `dispatch`, which the fused native lane
|
|
4895
6238
|
// bypasses - so an app that declares trust routes through the fetch lane (where `c.clientIp`
|
|
4896
6239
|
// resolves) instead of Bun's native table. The allocation-free default keeps native fusion.
|
|
4897
6240
|
if (
|
|
4898
|
-
this.onRequestHooks.length > 0 ||
|
|
6241
|
+
(this.onRequestHooks.length > 0 && !this.bunNativeRequestHooksSafe) ||
|
|
4899
6242
|
this.wsRouteCount > 0 ||
|
|
6243
|
+
this.preRouteMountCount > 0 ||
|
|
4900
6244
|
this.clientIpTrust !== undefined
|
|
4901
6245
|
) {
|
|
4902
6246
|
return undefined
|
|
@@ -4905,7 +6249,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
4905
6249
|
const routes: BunNativeRoutes = Object.create(null) as BunNativeRoutes
|
|
4906
6250
|
const mayUseFusedNative =
|
|
4907
6251
|
this.requestTimeoutMs === 0 &&
|
|
4908
|
-
this.onResponseHooks.length === 0 &&
|
|
6252
|
+
(this.onResponseHooks.length === 0 || this.bunNativeResponseHeadersOnly) &&
|
|
4909
6253
|
this.onResponseFinalizedHooks.length === 0 &&
|
|
4910
6254
|
// The capacity gate must wrap every request; the fused lane bypasses fetchMatched, so enabling
|
|
4911
6255
|
// admission drops fusion (native matching stays) and routes through the gated matched lane.
|
|
@@ -4913,23 +6257,75 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
4913
6257
|
const unboundedSignal = mayUseFusedNative ? getNeverAbortSignal() : undefined
|
|
4914
6258
|
const unboundedBudget = mayUseFusedNative ? getUnboundedRequestBudget() : undefined
|
|
4915
6259
|
let count = 0
|
|
4916
|
-
|
|
4917
|
-
|
|
4918
|
-
|
|
4919
|
-
|
|
4920
|
-
|
|
4921
|
-
|
|
6260
|
+
const all = this.catalog.entries()
|
|
6261
|
+
// A path with a wildcard or a part-literal segment cannot go in the table: Bun reads `/:name.json`
|
|
6262
|
+
// as one parameter named `name.json`. These are filed under each run of static segments they
|
|
6263
|
+
// start with, which is where a table path they could outrank looks for them.
|
|
6264
|
+
const under = new Map<string, CatalogRoute[]>()
|
|
6265
|
+
const lead = all.map((route) => {
|
|
6266
|
+
const segments = route.pattern.segments
|
|
6267
|
+
const plain = segments.every(
|
|
6268
|
+
(segment) => segment.kind === "static" || segment.kind === "param",
|
|
6269
|
+
)
|
|
6270
|
+
let prefix = ""
|
|
6271
|
+
for (const segment of segments) {
|
|
6272
|
+
if (!plain) {
|
|
6273
|
+
let bucket = under.get(prefix)
|
|
6274
|
+
if (bucket === undefined) {
|
|
6275
|
+
bucket = []
|
|
6276
|
+
under.set(prefix, bucket)
|
|
6277
|
+
}
|
|
6278
|
+
bucket.push(route)
|
|
6279
|
+
}
|
|
6280
|
+
if (segment.kind !== "static") break
|
|
6281
|
+
prefix += `/${segment.value}`
|
|
6282
|
+
}
|
|
6283
|
+
return plain ? prefix : undefined
|
|
6284
|
+
})
|
|
6285
|
+
// A preflight hook is intentionally handled by the fallback fetch path. Keeping OPTIONS out
|
|
6286
|
+
// of the native lane means Bun dispatches it through CORS's onRequest hook even when the
|
|
6287
|
+
// same path has native GET/POST handlers.
|
|
6288
|
+
const preflightOnFallback = this.onRequestHooks.length > 0 && this.bunNativeRequestHooksSafe
|
|
6289
|
+
// Where Bun could pass a request down: the methods served natively on a path with a parameter,
|
|
6290
|
+
// as segment count + method. Two table paths match one request only at the same segment count.
|
|
6291
|
+
const below = new Set<string>()
|
|
6292
|
+
const tableAt = (path: string): BunNativeMethodTable => {
|
|
6293
|
+
routes[path] ??= Object.create(null) as BunNativeMethodTable
|
|
6294
|
+
return routes[path]
|
|
6295
|
+
}
|
|
6296
|
+
for (let i = 0; i < all.length; i++) {
|
|
6297
|
+
const { method, path, pattern, entry } = all[i]!
|
|
6298
|
+
const run = lead[i]
|
|
6299
|
+
// The route is served from the table unless its path cannot be there, Bun's table has no slot
|
|
6300
|
+
// for its method, or a path outside the table outranks it: Bun would hand this route a request
|
|
6301
|
+
// the portable router gives to that one. An idempotent route stays off it too: its lanes run on
|
|
6302
|
+
// a buffered copy of the request, and Bun names the peer only of the request it delivered, so
|
|
6303
|
+
// the platform the table shares could not answer `c.clientIp` there.
|
|
6304
|
+
if (
|
|
6305
|
+
run === undefined ||
|
|
6306
|
+
entry.idempotent !== undefined ||
|
|
6307
|
+
!METHODS.includes(method as Method) ||
|
|
6308
|
+
(preflightOnFallback && method === "OPTIONS") ||
|
|
6309
|
+
under.get(run)?.some((other) => outranks(other.pattern.segments, pattern.segments))
|
|
6310
|
+
) {
|
|
6311
|
+
continue
|
|
4922
6312
|
}
|
|
4923
6313
|
const paramNames = pattern.paramNames
|
|
4924
6314
|
const fused = mayUseFusedNative ? entry.execution.fusedWeb : undefined
|
|
4925
|
-
|
|
6315
|
+
tableAt(path)[method as Method] = this.compileBunNativeHandler(
|
|
4926
6316
|
entry,
|
|
4927
6317
|
paramNames,
|
|
4928
6318
|
fused,
|
|
4929
6319
|
unboundedSignal,
|
|
4930
6320
|
unboundedBudget,
|
|
6321
|
+
peer,
|
|
6322
|
+
fallback,
|
|
4931
6323
|
)
|
|
4932
6324
|
count += 1
|
|
6325
|
+
if (paramNames.length > 0) {
|
|
6326
|
+
below.add(pattern.segments.length + method)
|
|
6327
|
+
if (method === "GET") below.add(`${pattern.segments.length}HEAD`)
|
|
6328
|
+
}
|
|
4933
6329
|
}
|
|
4934
6330
|
if (count === 0) return undefined
|
|
4935
6331
|
// RFC 9110 §9.3.2: a GET route answers HEAD with identical status + headers (Bun strips the
|
|
@@ -4940,6 +6336,17 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
4940
6336
|
const methods = routes[path]!
|
|
4941
6337
|
if (methods.GET !== undefined) methods.HEAD ??= methods.GET
|
|
4942
6338
|
}
|
|
6339
|
+
// Bun picks among the paths its table holds for the request's method, so a path with no entry
|
|
6340
|
+
// for the method lets the request through to a less specific one. The portable router stops at
|
|
6341
|
+
// the most specific path and answers 405. Every path the table could hold therefore gets the
|
|
6342
|
+
// portable dispatcher under each method a less specific path might serve.
|
|
6343
|
+
for (let i = 0; i < all.length; i++) {
|
|
6344
|
+
if (lead[i] === undefined) continue
|
|
6345
|
+
const { path, pattern } = all[i]!
|
|
6346
|
+
for (const method of METHODS) {
|
|
6347
|
+
if (below.has(pattern.segments.length + method)) tableAt(path)[method] ??= fallback
|
|
6348
|
+
}
|
|
6349
|
+
}
|
|
4943
6350
|
return routes
|
|
4944
6351
|
}
|
|
4945
6352
|
|
|
@@ -4964,6 +6371,10 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
4964
6371
|
* connection cut mid-flight regardless of `requestTimeoutMs`. Apps with such endpoints must raise
|
|
4965
6372
|
* this above their slowest expected response, which is why it is a first-class option and not a
|
|
4966
6373
|
* reason to drop down to `Bun.serve`. `0` disables the timeout entirely; max 255.
|
|
6374
|
+
*
|
|
6375
|
+
* `tls` serves HTTPS directly: `app.listen(443, { tls: { cert, key } })` with PEM text or bytes.
|
|
6376
|
+
* Requests then arrive with `https:` URLs. Behind a proxy or a platform that terminates TLS, leave
|
|
6377
|
+
* it unset.
|
|
4967
6378
|
*/
|
|
4968
6379
|
listen(
|
|
4969
6380
|
port: number,
|
|
@@ -4971,6 +6382,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
4971
6382
|
readonly reusePort?: boolean
|
|
4972
6383
|
readonly hostname?: string
|
|
4973
6384
|
readonly idleTimeoutSec?: number
|
|
6385
|
+
readonly tls?: ListenTlsOptions
|
|
4974
6386
|
},
|
|
4975
6387
|
): RunningServer {
|
|
4976
6388
|
if (typeof Bun === "undefined") {
|
|
@@ -4998,34 +6410,63 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
4998
6410
|
// because wsRouteCount > 0 means ws() ran, and ws() requires the runtime at registration.
|
|
4999
6411
|
// Native pub/sub when the app has WS routes and none validate outbound frames: `ws.subscribe` and
|
|
5000
6412
|
// `app.publish` go through Bun's own (uWebSockets) broadcast instead of the JS registry loop.
|
|
5001
|
-
|
|
5002
|
-
|
|
5003
|
-
|
|
5004
|
-
|
|
5005
|
-
|
|
5006
|
-
|
|
5007
|
-
|
|
5008
|
-
|
|
5009
|
-
|
|
5010
|
-
//
|
|
5011
|
-
|
|
5012
|
-
|
|
5013
|
-
|
|
6413
|
+
// A mounted app counts only while it has a WebSocket route (see `[GET_WS_RUNTIME]`), so composing
|
|
6414
|
+
// one without any needs no runtime here.
|
|
6415
|
+
const wsRuntime = this[GET_WS_RUNTIME]()
|
|
6416
|
+
if (wsRuntime === null) {
|
|
6417
|
+
throw new FrameworkError(
|
|
6418
|
+
"INVALID_WS_RUNTIME",
|
|
6419
|
+
"a Bun server with mounted WebSocket routes needs the websocket() runtime installed on the parent or child app",
|
|
6420
|
+
)
|
|
6421
|
+
}
|
|
6422
|
+
// Native pub/sub is safe only when every upgrade belongs to this app. A mounted child that takes
|
|
6423
|
+
// upgrades owns a different TopicRegistry, so force the per-connection JS dispatcher and carry
|
|
6424
|
+
// that registry in the upgrade outcome instead of accidentally broadcasting child sockets through
|
|
6425
|
+
// the parent.
|
|
6426
|
+
const nativePubsub =
|
|
6427
|
+
this.wsRouteCount > 0 && !this.mountsTakeUpgrades() && !this.wsHasValidatedSend
|
|
6428
|
+
const wsHandlers = wsRuntime?.bunHandlers(this.topics ?? wsRuntime.createTopics(), nativePubsub)
|
|
6429
|
+
// Spread rather than pass `hostname: undefined` (or an undefined idle timeout or TLS) - Bun treats
|
|
6430
|
+
// an explicit undefined as a value on some option paths, and omitting is what selects its own
|
|
6431
|
+
// defaults, 0.0.0.0 among them.
|
|
6432
|
+
const listening = {
|
|
6433
|
+
port,
|
|
6434
|
+
reusePort: options?.reusePort === true,
|
|
6435
|
+
...(options?.hostname === undefined ? {} : { hostname: options.hostname }),
|
|
6436
|
+
...(options?.idleTimeoutSec === undefined ? {} : { idleTimeout: options.idleTimeoutSec }),
|
|
6437
|
+
...(options?.tls === undefined ? {} : { tls: options.tls }),
|
|
6438
|
+
// A hook that throws rejects `fetch`; Bun's own answer to that is a development page carrying
|
|
6439
|
+
// the message, stack and source whenever NODE_ENV is not "production".
|
|
6440
|
+
error: (err: unknown) => {
|
|
6441
|
+
emitRequestErrorLog(this.logger, this.errorLogDetail, err, undefined)
|
|
6442
|
+
return jsonError(500, "internal_error")
|
|
6443
|
+
},
|
|
6444
|
+
}
|
|
6445
|
+
const fallback: BunNativeHandler = (req, server) => {
|
|
6446
|
+
// Bun has already framed and bounded this request body in its HTTP parser. Mark the
|
|
6447
|
+
// source before the portable fallback runs so body schemas use Bun's native `json()`
|
|
6448
|
+
// reader instead of the defensive arrayBuffer/decode path reserved for caller-built
|
|
6449
|
+
// Requests. Native route handlers mark themselves in compileBunNativeHandler; this
|
|
6450
|
+
// covers every request the native lane does not serve.
|
|
6451
|
+
markTrustedBodyFraming(req)
|
|
6452
|
+
return this.fetch(req, bunPeerPlatform(server, req) as Platform<EnvOf<Ctx>>)
|
|
6453
|
+
}
|
|
6454
|
+
// Bun names the socket peer of a request when its server is asked. A request served from the
|
|
6455
|
+
// native table carries this one platform, which asks only when a handler reads `c.clientIp`.
|
|
6456
|
+
const peer: PeerPlatform = {
|
|
6457
|
+
[PLATFORM_PEER]: (request) =>
|
|
6458
|
+
(running as unknown as BunPeerServer).requestIP(request as Request)?.address,
|
|
6459
|
+
}
|
|
6460
|
+
const nativeRoutes =
|
|
6461
|
+
wsHandlers === undefined ? this.buildBunNativeRoutes(fallback, peer) : undefined
|
|
5014
6462
|
const running = (wsHandlers === undefined
|
|
5015
6463
|
? Bun.serve({
|
|
5016
|
-
|
|
5017
|
-
reusePort,
|
|
5018
|
-
...bind,
|
|
5019
|
-
...idle,
|
|
6464
|
+
...listening,
|
|
5020
6465
|
...(nativeRoutes === undefined ? {} : { routes: nativeRoutes }),
|
|
5021
|
-
fetch:
|
|
5022
|
-
this.fetch(req, bunPeerPlatform(server, req) as Platform<EnvOf<Ctx>>),
|
|
6466
|
+
fetch: fallback,
|
|
5023
6467
|
})
|
|
5024
6468
|
: Bun.serve<BunWsData>({
|
|
5025
|
-
|
|
5026
|
-
reusePort,
|
|
5027
|
-
...bind,
|
|
5028
|
-
...idle,
|
|
6469
|
+
...listening,
|
|
5029
6470
|
fetch: (req, server) => this.bunFetchWithWebSocket(req, server),
|
|
5030
6471
|
// Bun's `ServerWebSocket<BunWsData>` is runtime-compatible with the handlers' structural
|
|
5031
6472
|
// `BunSocket` view (kept local so `Bun.*` types never leak into the published .d.ts); the
|
|
@@ -5041,7 +6482,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
5041
6482
|
})) as unknown as RunningServer
|
|
5042
6483
|
this.bunServer = running
|
|
5043
6484
|
// Bind `app.publish` to Bun's native broadcast now that the server handle exists. Guarded on the
|
|
5044
|
-
// method's presence so a runtime whose handle lacks it simply keeps the registry path.
|
|
6485
|
+
// method's presence so a runtime whose handle lacks it simply keeps the registry path. Cleared
|
|
6486
|
+
// first: a mounted app may have gained a WebSocket route since an earlier `listen()`.
|
|
6487
|
+
this.nativePublish = undefined
|
|
5045
6488
|
if (nativePubsub && typeof running.publish === "function") {
|
|
5046
6489
|
const native = running.publish.bind(running)
|
|
5047
6490
|
this.nativePublish = (topic, data) => {
|