@nifrajs/core 3.5.0 → 4.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +26 -0
- package/dist/assurance.d.ts +7 -0
- package/dist/assurance.d.ts.map +1 -1
- package/dist/assurance.js +39 -3
- package/dist/assurance.js.map +1 -1
- package/dist/capabilities.d.ts +20 -2
- package/dist/capabilities.d.ts.map +1 -1
- package/dist/capabilities.js +47 -15
- package/dist/capabilities.js.map +1 -1
- package/dist/diff.d.ts +2 -1
- package/dist/diff.d.ts.map +1 -1
- package/dist/diff.js +26 -2
- package/dist/diff.js.map +1 -1
- package/dist/durable-adapters.d.ts.map +1 -1
- package/dist/durable-adapters.js +16 -3
- package/dist/durable-adapters.js.map +1 -1
- package/dist/durable-effect.d.ts.map +1 -1
- package/dist/durable-effect.js +6 -3
- package/dist/durable-effect.js.map +1 -1
- package/dist/edge-kit.d.ts +2 -0
- package/dist/edge-kit.d.ts.map +1 -1
- package/dist/edge-kit.js +2 -0
- package/dist/edge-kit.js.map +1 -1
- package/dist/effect-lifecycle.d.ts.map +1 -1
- package/dist/effect-lifecycle.js +2 -1
- package/dist/effect-lifecycle.js.map +1 -1
- package/dist/effect-scope.d.ts.map +1 -1
- package/dist/effect-scope.js +1 -1
- package/dist/effect-scope.js.map +1 -1
- package/dist/errors.d.ts +1 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js.map +1 -1
- package/dist/evidence.d.ts +24 -1
- package/dist/evidence.d.ts.map +1 -1
- package/dist/evidence.js +168 -13
- package/dist/evidence.js.map +1 -1
- package/dist/idempotency.d.ts +31 -0
- package/dist/idempotency.d.ts.map +1 -1
- package/dist/idempotency.js +40 -8
- package/dist/idempotency.js.map +1 -1
- package/dist/index.d.ts +8 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -2
- package/dist/index.js.map +1 -1
- package/dist/internal/code-unit-order.d.ts +7 -0
- package/dist/internal/code-unit-order.d.ts.map +1 -0
- package/dist/internal/code-unit-order.js +9 -0
- package/dist/internal/code-unit-order.js.map +1 -0
- package/dist/internal/durable-saga-protocol.d.ts.map +1 -1
- package/dist/internal/durable-saga-protocol.js +2 -0
- package/dist/internal/durable-saga-protocol.js.map +1 -1
- package/dist/internal/route-catalog.d.ts +6 -6
- package/dist/internal/route-catalog.d.ts.map +1 -1
- package/dist/internal/route-catalog.js +20 -18
- package/dist/internal/route-catalog.js.map +1 -1
- package/dist/internal/route-compiler.d.ts +7 -6
- package/dist/internal/route-compiler.d.ts.map +1 -1
- package/dist/internal/route-compiler.js +36 -4
- package/dist/internal/route-compiler.js.map +1 -1
- package/dist/internal/route-execution.d.ts +7 -3
- package/dist/internal/route-execution.d.ts.map +1 -1
- package/dist/internal/route-execution.js.map +1 -1
- package/dist/internal/route-lanes.d.ts +1 -0
- package/dist/internal/route-lanes.d.ts.map +1 -1
- package/dist/internal/route-lanes.js +34 -29
- package/dist/internal/route-lanes.js.map +1 -1
- package/dist/internal/route-program.d.ts +8 -2
- package/dist/internal/route-program.d.ts.map +1 -1
- package/dist/internal/route-program.js +222 -20
- package/dist/internal/route-program.js.map +1 -1
- package/dist/ledger.d.ts.map +1 -1
- package/dist/ledger.js +8 -5
- package/dist/ledger.js.map +1 -1
- package/dist/manifest.d.ts.map +1 -1
- package/dist/manifest.js +41 -37
- package/dist/manifest.js.map +1 -1
- package/dist/mount.d.ts +36 -0
- package/dist/mount.d.ts.map +1 -1
- package/dist/mount.js +32 -0
- package/dist/mount.js.map +1 -1
- package/dist/reflection.d.ts +26 -0
- package/dist/reflection.d.ts.map +1 -1
- package/dist/reflection.js +65 -1
- package/dist/reflection.js.map +1 -1
- package/dist/response-observer.d.ts +1 -1
- package/dist/response-observer.d.ts.map +1 -1
- package/dist/response-observer.js.map +1 -1
- package/dist/router/overlap.d.ts +23 -0
- package/dist/router/overlap.d.ts.map +1 -0
- package/dist/router/overlap.js +224 -0
- package/dist/router/overlap.js.map +1 -0
- package/dist/router/pattern.d.ts +117 -2
- package/dist/router/pattern.d.ts.map +1 -1
- package/dist/router/pattern.js +283 -23
- package/dist/router/pattern.js.map +1 -1
- package/dist/router/router.d.ts +18 -4
- package/dist/router/router.d.ts.map +1 -1
- package/dist/router/router.js +51 -44
- package/dist/router/router.js.map +1 -1
- package/dist/server/auth.d.ts +53 -0
- package/dist/server/auth.d.ts.map +1 -0
- package/dist/server/auth.js +9 -0
- package/dist/server/auth.js.map +1 -0
- package/dist/server/bare-error-lane.d.ts +12 -7
- package/dist/server/bare-error-lane.d.ts.map +1 -1
- package/dist/server/bare-error-lane.js +13 -11
- package/dist/server/bare-error-lane.js.map +1 -1
- package/dist/server/body-lane.d.ts +20 -2
- package/dist/server/body-lane.d.ts.map +1 -1
- package/dist/server/body-lane.js +26 -7
- package/dist/server/body-lane.js.map +1 -1
- package/dist/server/body-parser.d.ts +68 -0
- package/dist/server/body-parser.d.ts.map +1 -0
- package/dist/server/body-parser.js +172 -0
- package/dist/server/body-parser.js.map +1 -0
- package/dist/server/body.d.ts.map +1 -1
- package/dist/server/body.js +62 -7
- package/dist/server/body.js.map +1 -1
- package/dist/server/client-ip.d.ts +8 -0
- package/dist/server/client-ip.d.ts.map +1 -1
- package/dist/server/client-ip.js +8 -0
- package/dist/server/client-ip.js.map +1 -1
- package/dist/server/context.d.ts +71 -6
- package/dist/server/context.d.ts.map +1 -1
- package/dist/server/contract.d.ts +15 -5
- package/dist/server/contract.d.ts.map +1 -1
- package/dist/server/contract.js +25 -4
- package/dist/server/contract.js.map +1 -1
- package/dist/server/core-copies.d.ts +2 -0
- package/dist/server/core-copies.d.ts.map +1 -0
- package/dist/server/core-copies.js +20 -0
- package/dist/server/core-copies.js.map +1 -0
- package/dist/server/edge.d.ts.map +1 -1
- package/dist/server/edge.js +2 -0
- package/dist/server/edge.js.map +1 -1
- package/dist/server/http.d.ts +11 -0
- package/dist/server/http.d.ts.map +1 -1
- package/dist/server/http.js +36 -0
- package/dist/server/http.js.map +1 -1
- package/dist/server/idempotency-lane.d.ts +6 -0
- package/dist/server/idempotency-lane.d.ts.map +1 -1
- package/dist/server/idempotency-lane.js +130 -73
- package/dist/server/idempotency-lane.js.map +1 -1
- package/dist/server/install.d.ts +4 -0
- package/dist/server/install.d.ts.map +1 -1
- package/dist/server/install.js +4 -0
- package/dist/server/install.js.map +1 -1
- package/dist/server/methods.d.ts +45 -0
- package/dist/server/methods.d.ts.map +1 -0
- package/dist/server/methods.js +37 -0
- package/dist/server/methods.js.map +1 -0
- package/dist/server/multipart.d.ts +63 -0
- package/dist/server/multipart.d.ts.map +1 -0
- package/dist/server/multipart.js +374 -0
- package/dist/server/multipart.js.map +1 -0
- package/dist/server/node-outcome-hook.d.ts +20 -0
- package/dist/server/node-outcome-hook.d.ts.map +1 -1
- package/dist/server/node-outcome-hook.js +15 -6
- package/dist/server/node-outcome-hook.js.map +1 -1
- package/dist/server/not-found-answer.d.ts +40 -0
- package/dist/server/not-found-answer.d.ts.map +1 -0
- package/dist/server/not-found-answer.js +56 -0
- package/dist/server/not-found-answer.js.map +1 -0
- package/dist/server/not-found.d.ts +23 -0
- package/dist/server/not-found.d.ts.map +1 -0
- package/dist/server/not-found.js +81 -0
- package/dist/server/not-found.js.map +1 -0
- package/dist/server/plugin.d.ts +26 -1
- package/dist/server/plugin.d.ts.map +1 -1
- package/dist/server/plugin.js.map +1 -1
- package/dist/server/proto-guard.d.ts +18 -5
- package/dist/server/proto-guard.d.ts.map +1 -1
- package/dist/server/proto-guard.js +114 -9
- package/dist/server/proto-guard.js.map +1 -1
- package/dist/server/query.d.ts +7 -0
- package/dist/server/query.d.ts.map +1 -1
- package/dist/server/query.js +47 -0
- package/dist/server/query.js.map +1 -1
- package/dist/server/registry.d.ts +13 -2
- package/dist/server/registry.d.ts.map +1 -1
- package/dist/server/request-context.d.ts +11 -0
- package/dist/server/request-context.d.ts.map +1 -1
- package/dist/server/request-context.js +33 -19
- package/dist/server/request-context.js.map +1 -1
- package/dist/server/request-lineage.d.ts +8 -0
- package/dist/server/request-lineage.d.ts.map +1 -0
- package/dist/server/request-lineage.js +14 -0
- package/dist/server/request-lineage.js.map +1 -0
- package/dist/server/request-target.d.ts +31 -0
- package/dist/server/request-target.d.ts.map +1 -0
- package/dist/server/request-target.js +44 -0
- package/dist/server/request-target.js.map +1 -0
- package/dist/server/respond.d.ts +1 -1
- package/dist/server/respond.d.ts.map +1 -1
- package/dist/server/respond.js +16 -4
- package/dist/server/respond.js.map +1 -1
- package/dist/server/response-contract-lane.d.ts +19 -1
- package/dist/server/response-contract-lane.d.ts.map +1 -1
- package/dist/server/response-contract-lane.js +59 -14
- package/dist/server/response-contract-lane.js.map +1 -1
- package/dist/server/response-observer-runtime.d.ts +9 -5
- package/dist/server/response-observer-runtime.d.ts.map +1 -1
- package/dist/server/response-observer-runtime.js +10 -6
- package/dist/server/response-observer-runtime.js.map +1 -1
- package/dist/server/runtime-core.d.ts +2 -0
- package/dist/server/runtime-core.d.ts.map +1 -1
- package/dist/server/runtime-core.js.map +1 -1
- package/dist/server/server-types.d.ts +53 -3
- package/dist/server/server-types.d.ts.map +1 -1
- package/dist/server/server.d.ts +191 -19
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +1206 -218
- package/dist/server/server.js.map +1 -1
- package/dist/server/sse.d.ts.map +1 -1
- package/dist/server/sse.js +10 -7
- package/dist/server/sse.js.map +1 -1
- package/dist/server/static-headers.d.ts +3 -0
- package/dist/server/static-headers.d.ts.map +1 -1
- package/dist/server/static-headers.js +19 -1
- package/dist/server/static-headers.js.map +1 -1
- package/dist/server/transport-lane.d.ts +8 -0
- package/dist/server/transport-lane.d.ts.map +1 -1
- package/dist/server/transport-lane.js +9 -8
- package/dist/server/transport-lane.js.map +1 -1
- package/dist/server/validation.d.ts.map +1 -1
- package/dist/server/validation.js +5 -1
- package/dist/server/validation.js.map +1 -1
- package/dist/server/websocket.d.ts +10 -3
- package/dist/server/websocket.d.ts.map +1 -1
- package/dist/server/websocket.js +7 -4
- package/dist/server/websocket.js.map +1 -1
- package/dist/server/ws-bun.d.ts +2 -0
- package/dist/server/ws-bun.d.ts.map +1 -1
- package/dist/server/ws-bun.js +6 -3
- package/dist/server/ws-bun.js.map +1 -1
- package/dist/server/ws-hook.d.ts +2 -1
- package/dist/server/ws-hook.d.ts.map +1 -1
- package/dist/server.d.ts +7 -4
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +4 -1
- package/dist/server.js.map +1 -1
- package/dist/single-copy-register.js +5 -0
- package/dist/single-copy-register.js.map +1 -1
- package/dist/single-copy.d.ts +46 -3
- package/dist/single-copy.d.ts.map +1 -1
- package/dist/single-copy.js +125 -12
- package/dist/single-copy.js.map +1 -1
- package/dist/tool-contract.d.ts +23 -0
- package/dist/tool-contract.d.ts.map +1 -1
- package/dist/tool-contract.js +37 -3
- package/dist/tool-contract.js.map +1 -1
- package/dist/transport-codec.d.ts.map +1 -1
- package/dist/transport-codec.js +2 -1
- package/dist/transport-codec.js.map +1 -1
- package/package.json +27 -2
- package/src/assurance.ts +52 -2
- package/src/capabilities.ts +81 -22
- package/src/diff.ts +31 -6
- package/src/durable-adapters.ts +20 -3
- package/src/durable-effect.ts +10 -3
- package/src/edge-kit.ts +7 -0
- package/src/effect-lifecycle.ts +5 -1
- package/src/effect-scope.ts +2 -1
- package/src/errors.ts +3 -0
- package/src/evidence.ts +237 -12
- package/src/idempotency.ts +72 -8
- package/src/index.ts +40 -3
- package/src/internal/code-unit-order.ts +8 -0
- package/src/internal/durable-saga-protocol.ts +2 -0
- package/src/internal/route-catalog.ts +19 -18
- package/src/internal/route-compiler.ts +62 -13
- package/src/internal/route-execution.ts +13 -3
- package/src/internal/route-lanes.ts +37 -28
- package/src/internal/route-program.ts +434 -20
- package/src/ledger.ts +11 -5
- package/src/manifest.ts +41 -36
- package/src/mount.ts +62 -0
- package/src/reflection.ts +84 -1
- package/src/response-observer.ts +1 -0
- package/src/router/overlap.ts +258 -0
- package/src/router/pattern.ts +349 -25
- package/src/router/router.ts +76 -51
- package/src/server/auth.ts +75 -0
- package/src/server/bare-error-lane.ts +12 -11
- package/src/server/body-lane.ts +46 -6
- package/src/server/body-parser.ts +241 -0
- package/src/server/body.ts +82 -9
- package/src/server/client-ip.ts +9 -0
- package/src/server/context.ts +219 -12
- package/src/server/contract.ts +54 -9
- package/src/server/core-copies.ts +21 -0
- package/src/server/edge.ts +2 -0
- package/src/server/http.ts +34 -0
- package/src/server/idempotency-lane.ts +147 -72
- package/src/server/install.ts +6 -0
- package/src/server/methods.ts +143 -0
- package/src/server/multipart.ts +424 -0
- package/src/server/node-outcome-hook.ts +40 -7
- package/src/server/not-found-answer.ts +109 -0
- package/src/server/not-found.ts +121 -0
- package/src/server/plugin.ts +45 -1
- package/src/server/proto-guard.ts +128 -9
- package/src/server/query.ts +49 -0
- package/src/server/registry.ts +17 -2
- package/src/server/request-context.ts +44 -16
- package/src/server/request-lineage.ts +15 -0
- package/src/server/request-target.ts +46 -0
- package/src/server/respond.ts +14 -3
- package/src/server/response-contract-lane.ts +97 -16
- package/src/server/response-observer-runtime.ts +37 -8
- package/src/server/runtime-core.ts +2 -0
- package/src/server/server-types.ts +59 -2
- package/src/server/server.ts +1613 -269
- package/src/server/sse.ts +10 -8
- package/src/server/static-headers.ts +22 -1
- package/src/server/transport-lane.ts +17 -9
- package/src/server/validation.ts +7 -1
- package/src/server/websocket.ts +23 -9
- package/src/server/ws-bun.ts +8 -3
- package/src/server/ws-hook.ts +2 -1
- package/src/server.ts +38 -2
- package/src/single-copy-register.ts +5 -0
- package/src/single-copy.ts +160 -12
- package/src/tool-contract.ts +66 -3
- package/src/transport-codec.ts +2 -1
package/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,13 +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" }
|
|
329
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
|
|
330
418
|
|
|
331
419
|
/** `app.ws()` (and everything downstream of it) needs the runtime `@nifrajs/core/ws` registers. */
|
|
332
420
|
function requireWsRuntime(runtime: WsRuntime | undefined): WsRuntime {
|
|
@@ -408,10 +496,13 @@ export type {
|
|
|
408
496
|
AdmissionController,
|
|
409
497
|
AdmissionDecision,
|
|
410
498
|
FetchHandler,
|
|
499
|
+
ListenTlsOptions,
|
|
411
500
|
McpPromptDescriptor,
|
|
412
501
|
McpResourceDescriptor,
|
|
413
502
|
Middleware,
|
|
503
|
+
MountableApp,
|
|
414
504
|
MountFetchOptions,
|
|
505
|
+
MountOptions,
|
|
415
506
|
PromptArgument,
|
|
416
507
|
PromptMessage,
|
|
417
508
|
ResponseFinalization,
|
|
@@ -436,8 +527,10 @@ export {
|
|
|
436
527
|
defineIdentityPlugin,
|
|
437
528
|
definePlugin,
|
|
438
529
|
defineRouterPlugin,
|
|
530
|
+
type MethodRoutesPlugin,
|
|
439
531
|
type NifraPlugin,
|
|
440
532
|
type PluginTypeCollapsed,
|
|
533
|
+
type WithMethodRoutes,
|
|
441
534
|
} from "./plugin.ts"
|
|
442
535
|
export type { IdentityPlugin }
|
|
443
536
|
|
|
@@ -462,13 +555,31 @@ export { plainValidationError } from "./validation.ts"
|
|
|
462
555
|
// importers keep resolving `searchOf`/`queryObjectOf`/`QueryValue` from here.
|
|
463
556
|
export { type QueryValue, queryObjectOf, searchOf }
|
|
464
557
|
|
|
465
|
-
|
|
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 {
|
|
466
568
|
for (const key in params) {
|
|
467
|
-
if (params[key]
|
|
569
|
+
if (unroutedParam(params[key])) return true
|
|
468
570
|
}
|
|
469
571
|
return false
|
|
470
572
|
}
|
|
471
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
|
+
|
|
472
583
|
function normalizeMountPrefix(path: string): string {
|
|
473
584
|
if (!path.startsWith("/") || path.includes("?") || path.includes("#")) {
|
|
474
585
|
throw new TypeError("mountFetch path must be an absolute pathname without a query or hash")
|
|
@@ -487,6 +598,18 @@ function underMountPrefix(pathname: string, prefix: string): boolean {
|
|
|
487
598
|
return prefix === "/" || pathname === prefix || pathname.startsWith(`${prefix}/`)
|
|
488
599
|
}
|
|
489
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
|
+
|
|
490
613
|
function stripMountPrefix(request: Request, prefix: string): Request {
|
|
491
614
|
if (prefix === "/") return request
|
|
492
615
|
const url = new URL(request.url)
|
|
@@ -536,6 +659,88 @@ export type NifraFeatureVersion = FeatureVersionOf<Version>
|
|
|
536
659
|
const webResponseOf = (result: Response | ResponseResult): Response =>
|
|
537
660
|
result instanceof Response ? result : toResponse(result, EMPTY_RESPONSE_CONTROLS)
|
|
538
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
|
+
|
|
539
744
|
// Stable module-level finalizers so `fetch`/`resolveNode` allocate no per-request closures.
|
|
540
745
|
const IDENTITY_RESPONSE = (response: Response | ResponseResult): Response => webResponseOf(response)
|
|
541
746
|
const RESPONSE_TIMEOUT = (): Response => jsonError(503, "request_timeout")
|
|
@@ -661,6 +866,10 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
661
866
|
/** WebSocket routes, matched separately at upgrade time (a GET + `Upgrade: websocket`). */
|
|
662
867
|
private readonly wsRouter: Router<WsEntry>
|
|
663
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
|
|
664
873
|
/** In-process pub/sub backing `ws.subscribe(topic)` + `app.publish(topic, data)` (single-instance).
|
|
665
874
|
* Created by the first `app.ws()` via the `@nifrajs/core/ws` runtime - `undefined` until then, so a
|
|
666
875
|
* no-WebSocket app never constructs (or bundles) it. */
|
|
@@ -670,7 +879,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
670
879
|
* rather than delegate to Bun's native `server.publish`. */
|
|
671
880
|
private wsHasValidatedSend = false
|
|
672
881
|
/** Bun's native topic broadcast, bound by `listen()` in native-pubsub mode; `app.publish` uses it
|
|
673
|
-
* 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. */
|
|
674
884
|
private nativePublish:
|
|
675
885
|
| ((topic: string, data: string | ArrayBufferView | ArrayBuffer) => void)
|
|
676
886
|
| undefined
|
|
@@ -691,7 +901,10 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
691
901
|
private readonly deadlineAdmissionOptions: DeadlineAdmissionOptions
|
|
692
902
|
private readonly gracefulSignals: boolean
|
|
693
903
|
private readonly stopHooks: StopHook[]
|
|
694
|
-
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
|
|
695
908
|
/** Capacity-admission gate; `undefined` = off (the request path pays nothing). */
|
|
696
909
|
private readonly capacityGate: AdmissionController | undefined
|
|
697
910
|
private readonly onCapabilityUse: ((event: CapabilityUseEvent) => void) | undefined
|
|
@@ -705,6 +918,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
705
918
|
private idempotencyRuntime: IdempotencyRuntime | undefined
|
|
706
919
|
/** Installed opt-in runtime for `.tool()`/`.resource()`/`.prompt()`; `undefined` until `.use(mcp())`. */
|
|
707
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
|
|
708
923
|
/** Installed Node-direct renderer for direct `resolveNode()` callers; `undefined` until `.use(nodeDirect())`. */
|
|
709
924
|
private nodeOutcomeRuntime: NodeOutcomeRuntime | undefined
|
|
710
925
|
/** Installed streaming runtime for `.sse()` routes; `undefined` until `.use(streaming())`. */
|
|
@@ -719,6 +934,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
719
934
|
private bunServer: RunningServer | undefined
|
|
720
935
|
private sealed: boolean
|
|
721
936
|
private readonly derives: RawDerive[]
|
|
937
|
+
/** Dedicated authentication stages captured by subsequent routes. */
|
|
938
|
+
private readonly authStages: RawAuthStage[]
|
|
722
939
|
private readonly decorations: Record<string, unknown>
|
|
723
940
|
private readonly beforeHandleHooks: RawBeforeHandle[]
|
|
724
941
|
private readonly afterHandleHooks: RawAfterHandle[]
|
|
@@ -728,10 +945,14 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
728
945
|
private readonly onNodeRequestHooks: Array<NodeRequestHook | undefined>
|
|
729
946
|
/** Registration-time eligibility for the Node request twin lane. */
|
|
730
947
|
private nodeRequestHooksComplete: boolean
|
|
948
|
+
/** True when every request hook is safe to skip on a Bun native route. */
|
|
949
|
+
private bunNativeRequestHooksSafe: boolean
|
|
731
950
|
private readonly onResponseHooks: RawOnResponse[]
|
|
732
951
|
private readonly onNodeResponseHooks: Array<NodeResponseHook | undefined>
|
|
733
952
|
/** Registration-time eligibility for the Node response twin lane. */
|
|
734
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
|
|
735
956
|
/**
|
|
736
957
|
* True once a bundle registers a RAW `onNodeResponse` twin - one handed the outcome's header
|
|
737
958
|
* record itself rather than the case-normalizing view. Their names are contractually the wire
|
|
@@ -747,6 +968,11 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
747
968
|
* `onResponse` hook would have disabled.
|
|
748
969
|
*/
|
|
749
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
|
|
750
976
|
/** `wrapResponse` for the Web lanes: identity until static headers exist to fold into the
|
|
751
977
|
* framework's own error/404/timeout renders, which are built outside the header init. */
|
|
752
978
|
private wrapWebResponse: (response: Response | ResponseResult) => Response
|
|
@@ -761,8 +987,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
761
987
|
private readonly responseSources: WeakMap<object, Request>
|
|
762
988
|
/** Memoized NodeRequestContext per plain-`Request` source - see {@link nodeRequestContextOf}. */
|
|
763
989
|
private readonly nodeContexts: WeakMap<object, NodeRequestContext>
|
|
764
|
-
/**
|
|
765
|
-
private readonly appliedPlugins:
|
|
990
|
+
/** Plugins/middleware already applied via `use`, by name - for idempotent dedupe. */
|
|
991
|
+
private readonly appliedPlugins: Map<string, unknown>
|
|
766
992
|
/** Order-scoped evidence captured by routes registered after an assured plugin. */
|
|
767
993
|
private readonly activeAssurance: AssuranceDeclaration[]
|
|
768
994
|
/** App-wide evidence from global hooks; applies retroactively to every route. */
|
|
@@ -770,7 +996,14 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
770
996
|
/** App-declared MCP resources / prompts (via {@link resource} / {@link prompt}), read by `nifra mcp`. */
|
|
771
997
|
private readonly mcpResourceList: McpResourceDescriptor[]
|
|
772
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
|
|
773
1004
|
constructor(options: ServerOptions = {}) {
|
|
1005
|
+
this.options = options
|
|
1006
|
+
this.routePrefix = ""
|
|
774
1007
|
this.catalog = new RouteCatalog()
|
|
775
1008
|
this.wsRouter = new Router<WsEntry>()
|
|
776
1009
|
this.wsRouteCount = 0
|
|
@@ -804,6 +1037,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
804
1037
|
this.gracefulSignals = options.gracefulSignals ?? false
|
|
805
1038
|
this.stopHooks = []
|
|
806
1039
|
this.fetchMounts = []
|
|
1040
|
+
this.mountOrder = 0
|
|
1041
|
+
this.preRouteMountCount = 0
|
|
807
1042
|
this.capacityGate = options.admission
|
|
808
1043
|
this.onCapabilityUse = options.onCapabilityUse
|
|
809
1044
|
this.capabilityInterceptors = []
|
|
@@ -826,6 +1061,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
826
1061
|
beginHookAudit(this, options.unusedScopedHooks ?? "warn", options.logger !== undefined)
|
|
827
1062
|
}
|
|
828
1063
|
this.derives = []
|
|
1064
|
+
this.authStages = []
|
|
829
1065
|
this.decorations = {}
|
|
830
1066
|
this.beforeHandleHooks = []
|
|
831
1067
|
this.afterHandleHooks = []
|
|
@@ -834,9 +1070,11 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
834
1070
|
this.onRequestHooks = []
|
|
835
1071
|
this.onNodeRequestHooks = []
|
|
836
1072
|
this.nodeRequestHooksComplete = true
|
|
1073
|
+
this.bunNativeRequestHooksSafe = true
|
|
837
1074
|
this.onResponseHooks = []
|
|
838
1075
|
this.onNodeResponseHooks = []
|
|
839
1076
|
this.nodeResponseHooksComplete = true
|
|
1077
|
+
this.bunNativeResponseHeadersOnly = true
|
|
840
1078
|
this.hasRawNodeResponseHook = false
|
|
841
1079
|
this.onResponseFinalizedHooks = []
|
|
842
1080
|
this.staticResponseHeaders = undefined
|
|
@@ -847,7 +1085,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
847
1085
|
this.responseRequests = new WeakMap()
|
|
848
1086
|
this.responseSources = new WeakMap()
|
|
849
1087
|
this.nodeContexts = new WeakMap()
|
|
850
|
-
this.appliedPlugins = new
|
|
1088
|
+
this.appliedPlugins = new Map()
|
|
851
1089
|
this.activeAssurance = []
|
|
852
1090
|
this.globalAssurance = []
|
|
853
1091
|
this.mcpResourceList = []
|
|
@@ -858,7 +1096,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
858
1096
|
if (this.sealed) {
|
|
859
1097
|
throw new FrameworkError(
|
|
860
1098
|
"SERVER_SEALED",
|
|
861
|
-
`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`,
|
|
862
1100
|
)
|
|
863
1101
|
}
|
|
864
1102
|
}
|
|
@@ -883,13 +1121,76 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
883
1121
|
): this {
|
|
884
1122
|
this.assertConfigurable("mountFetch()")
|
|
885
1123
|
if (typeof handler !== "function") throw new TypeError("mountFetch handler must be a function")
|
|
886
|
-
const mount: FetchMount = {
|
|
1124
|
+
const mount: FetchMount<EnvOf<Ctx>> = {
|
|
887
1125
|
path: normalizeMountPrefix(path),
|
|
888
|
-
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
|
+
}),
|
|
889
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,
|
|
890
1189
|
}
|
|
891
1190
|
this.fetchMounts.push(mount)
|
|
892
|
-
this.fetchMounts.sort(
|
|
1191
|
+
this.fetchMounts.sort(compareMounts)
|
|
1192
|
+
this.preRouteMountCount += 1
|
|
1193
|
+
wsChanges += 1
|
|
893
1194
|
return this
|
|
894
1195
|
}
|
|
895
1196
|
|
|
@@ -927,6 +1228,33 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
927
1228
|
* by none before; one added after the last route reaches nothing
|
|
928
1229
|
* (see {@link ServerOptions.unusedScopedHooks}).
|
|
929
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
|
+
|
|
930
1258
|
decorate<const K extends string, V>(key: K, value: V): Server<R, Ctx & Record<K, V>, HookOutput> {
|
|
931
1259
|
this.assertConfigurable("decorate()")
|
|
932
1260
|
this.decorations[key] = value
|
|
@@ -946,6 +1274,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
946
1274
|
this.onRequestHooks.push(fn as RawOnRequest)
|
|
947
1275
|
this.onNodeRequestHooks.push(undefined)
|
|
948
1276
|
this.nodeRequestHooksComplete = false
|
|
1277
|
+
this.bunNativeRequestHooksSafe = false
|
|
949
1278
|
return this
|
|
950
1279
|
}
|
|
951
1280
|
|
|
@@ -1050,6 +1379,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
1050
1379
|
this.onResponseHooks.push(fn)
|
|
1051
1380
|
this.onNodeResponseHooks.push(undefined)
|
|
1052
1381
|
this.nodeResponseHooksComplete = false
|
|
1382
|
+
this.bunNativeResponseHeadersOnly = false
|
|
1053
1383
|
return this
|
|
1054
1384
|
}
|
|
1055
1385
|
|
|
@@ -1067,7 +1397,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
1067
1397
|
* app.responseHeaders({ "x-frame-options": "DENY", "referrer-policy": "no-referrer" })
|
|
1068
1398
|
*
|
|
1069
1399
|
* They are DEFAULTS: a value the request itself produced (`c.set.headers`, or a response hook)
|
|
1070
|
-
* 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
|
|
1071
1402
|
* name, `__proto__`, or a name the render owns (`content-type`, `content-length`,
|
|
1072
1403
|
* `transfer-encoding`, `set-cookie`) throws a `TypeError` at wire-up.
|
|
1073
1404
|
*
|
|
@@ -1101,7 +1432,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
1101
1432
|
// serialized-body marker survives for later `onResponseBody`/`onResponseRaw` observers and the
|
|
1102
1433
|
// Node writer; reconstructing a `Response` here would strip that marker and reclassify the Node
|
|
1103
1434
|
// outcome from `json` to a generic `response`. A guarded foreign response takes its clone path.
|
|
1104
|
-
const statics = buildStaticResponseHeaders(record)
|
|
1435
|
+
const statics = buildStaticResponseHeaders(record, this.inheritedStatics)
|
|
1105
1436
|
this.onResponseHooks.push((response) => applyStaticResponseHeaders(response, statics))
|
|
1106
1437
|
this.onNodeResponseHooks.push(undefined)
|
|
1107
1438
|
this.nodeResponseHooksComplete = false
|
|
@@ -1111,7 +1442,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
1111
1442
|
this.staticResponseHeaders === undefined
|
|
1112
1443
|
? record
|
|
1113
1444
|
: { ...this.staticResponseHeaders.record, ...record }
|
|
1114
|
-
this.staticResponseHeaders = buildStaticResponseHeaders(merged)
|
|
1445
|
+
this.staticResponseHeaders = buildStaticResponseHeaders(merged, this.inheritedStatics)
|
|
1115
1446
|
const statics = this.staticResponseHeaders
|
|
1116
1447
|
this.wrapWebResponse = (response) =>
|
|
1117
1448
|
applyStaticResponseHeaders(webResponseOf(response), statics)
|
|
@@ -1150,6 +1481,16 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
1150
1481
|
return this
|
|
1151
1482
|
}
|
|
1152
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>
|
|
1153
1494
|
/** Enable the opt-in portable response observer methods. */
|
|
1154
1495
|
use(plugin: ResponseObserverPlugin): this & ResponseObserverMethods
|
|
1155
1496
|
/**
|
|
@@ -1174,7 +1515,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
1174
1515
|
* Apply a **plugin function** - `(app) => app`, typically built with {@link definePlugin}. It's
|
|
1175
1516
|
* called with `this` and its result is returned, so an inline plugin's `derive`/`decorate` thread
|
|
1176
1517
|
* the added context to handlers defined after `use` (the overload is generic over the concrete
|
|
1177
|
-
* `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.
|
|
1178
1520
|
*
|
|
1179
1521
|
* If the plugin's return type is unpinned - `Server<any, any>`, as a hand-rolled `(app) => app` or a
|
|
1180
1522
|
* `NifraPlugin<AnyServer, AnyServer>` infers (e.g. an auth plugin whose own types collapsed) - this
|
|
@@ -1195,22 +1537,43 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
1195
1537
|
*/
|
|
1196
1538
|
use<M extends Middleware>(mw: M): Server<R, Ctx, HookOutput | MiddlewareOutputOf<M>>
|
|
1197
1539
|
use(mw: Middleware): this
|
|
1198
|
-
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)
|
|
1199
1548
|
this.assertConfigurable("use()")
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
if (name
|
|
1203
|
-
|
|
1204
|
-
|
|
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
|
|
1205
1562
|
}
|
|
1563
|
+
this.appliedPlugins.set(name, arg)
|
|
1564
|
+
}
|
|
1565
|
+
if (typeof arg === "function") {
|
|
1206
1566
|
const evidence = assuranceDeclarationsOf(arg)
|
|
1207
1567
|
const pluginOnly = evidence.filter((item) => item.scope === "plugin")
|
|
1208
1568
|
this.globalAssurance.push(...evidence.filter((item) => item.scope === "global"))
|
|
1209
1569
|
this.activeAssurance.push(...evidence.filter((item) => item.scope === "subsequent"))
|
|
1210
1570
|
this.activeAssurance.push(...pluginOnly)
|
|
1571
|
+
const inherited = this.inheritedPlugins
|
|
1572
|
+
this.inheritedPlugins = undefined
|
|
1211
1573
|
try {
|
|
1212
1574
|
return arg(this)
|
|
1213
1575
|
} finally {
|
|
1576
|
+
this.inheritedPlugins = inherited
|
|
1214
1577
|
// Remove only this plugin's temporary evidence. Nested assured plugins may deliberately leave
|
|
1215
1578
|
// subsequent evidence active, so truncating the whole array would lose real ordering semantics.
|
|
1216
1579
|
for (const item of pluginOnly) {
|
|
@@ -1219,10 +1582,6 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
1219
1582
|
}
|
|
1220
1583
|
}
|
|
1221
1584
|
}
|
|
1222
|
-
if (arg.name !== undefined) {
|
|
1223
|
-
if (this.appliedPlugins.has(arg.name)) return this
|
|
1224
|
-
this.appliedPlugins.add(arg.name)
|
|
1225
|
-
}
|
|
1226
1585
|
const evidence = assuranceDeclarationsOf(arg)
|
|
1227
1586
|
if (evidence.some((item) => item.scope === "plugin")) {
|
|
1228
1587
|
throw new Error('route assurance: scope "plugin" may only annotate a plugin function')
|
|
@@ -1245,6 +1604,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
1245
1604
|
this.onRequestHooks.push(arg.onRequest as RawOnRequest)
|
|
1246
1605
|
this.onNodeRequestHooks.push(arg.onNodeRequest)
|
|
1247
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
|
+
}
|
|
1248
1610
|
} else if (arg.onNodeRequest !== undefined) {
|
|
1249
1611
|
throw new TypeError("onNodeRequest() requires a paired onRequest() hook")
|
|
1250
1612
|
}
|
|
@@ -1257,16 +1619,26 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
1257
1619
|
this.onNodeResponseHooks.push(arg.onNodeResponse)
|
|
1258
1620
|
if (arg.onNodeResponse === undefined) this.nodeResponseHooksComplete = false
|
|
1259
1621
|
else this.hasRawNodeResponseHook = true
|
|
1622
|
+
this.bunNativeResponseHeadersOnly = false
|
|
1260
1623
|
} else if (arg.onNodeResponse !== undefined) {
|
|
1261
1624
|
throw new TypeError("onNodeResponse() requires a paired onResponse() hook")
|
|
1262
1625
|
}
|
|
1263
1626
|
// Before the bundle's own hooks: a bundle declaring both means its static values are the
|
|
1264
1627
|
// defaults its hook may then override, which is the order a single bundle reads in.
|
|
1265
1628
|
if (arg.responseHeaders !== undefined) this.responseHeaders(arg.responseHeaders)
|
|
1266
|
-
if (arg.onResponseHeaders !== undefined)
|
|
1267
|
-
this.responseObserverMethods().onResponseHeaders(
|
|
1268
|
-
|
|
1269
|
-
|
|
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
|
+
}
|
|
1270
1642
|
if (arg.onResponseRaw !== undefined)
|
|
1271
1643
|
this.responseObserverMethods().onResponseRaw(arg.onResponseRaw)
|
|
1272
1644
|
if (arg.onResponseFinalized !== undefined) this.onResponseFinalized(arg.onResponseFinalized)
|
|
@@ -1595,10 +1967,10 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
1595
1967
|
this.topics ??= runtime.createTopics()
|
|
1596
1968
|
// A `messageSchema` wraps `message` with validation once, here - every adapter then dispatches
|
|
1597
1969
|
// already-validated, typed messages (Bun/Deno/Node/Workers) with no per-adapter code.
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
})
|
|
1970
|
+
const entry = { handler: runtime.wrapHandler(handler as WebSocketHandler, this.protoPoisoning) }
|
|
1971
|
+
for (const form of expandOptionalParams(path)) this.wsRouter.add("GET", form, entry)
|
|
1601
1972
|
this.wsRouteCount += 1
|
|
1973
|
+
wsChanges += 1
|
|
1602
1974
|
return this as never
|
|
1603
1975
|
}
|
|
1604
1976
|
|
|
@@ -1645,34 +2017,54 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
1645
2017
|
* is the "compiled", order-scoped per-route chain.
|
|
1646
2018
|
*/
|
|
1647
2019
|
register(
|
|
1648
|
-
method:
|
|
2020
|
+
method: string,
|
|
1649
2021
|
path: string,
|
|
1650
2022
|
schema: RouteSchema | undefined,
|
|
1651
2023
|
handler: (context: never) => unknown,
|
|
1652
2024
|
): void {
|
|
1653
|
-
this.
|
|
1654
|
-
this.catalog.add(this.prepareRoute(method, path, schema, handler))
|
|
2025
|
+
this.registerBatch([{ method, path, schema, handler }])
|
|
1655
2026
|
}
|
|
1656
2027
|
|
|
1657
2028
|
/** Register a contract/group route batch atomically. Every route captures the same current chain it
|
|
1658
|
-
* 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. */
|
|
1659
2033
|
registerBatch(
|
|
1660
2034
|
routes: readonly {
|
|
1661
|
-
readonly method:
|
|
2035
|
+
readonly method: string
|
|
1662
2036
|
readonly path: string
|
|
1663
2037
|
readonly schema: RouteSchema | undefined
|
|
1664
2038
|
readonly handler: (context: never) => unknown
|
|
1665
2039
|
}[],
|
|
1666
2040
|
): void {
|
|
1667
2041
|
this.assertConfigurable("route registration")
|
|
1668
|
-
const staged =
|
|
1669
|
-
|
|
1670
|
-
|
|
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
|
+
}
|
|
1671
2048
|
this.catalog.addBatch(staged)
|
|
1672
2049
|
}
|
|
1673
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
|
+
|
|
1674
2066
|
private prepareRoute(
|
|
1675
|
-
method:
|
|
2067
|
+
method: string,
|
|
1676
2068
|
path: string,
|
|
1677
2069
|
schema: RouteSchema | undefined,
|
|
1678
2070
|
handler: (context: never) => unknown,
|
|
@@ -1690,6 +2082,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
1690
2082
|
idempotencyRuntime: this.idempotencyRuntime,
|
|
1691
2083
|
responseContractRuntime: this.responseContractRuntime,
|
|
1692
2084
|
derives: this.derives,
|
|
2085
|
+
authStages: this.authStages,
|
|
1693
2086
|
beforeHandleHooks: this.beforeHandleHooks,
|
|
1694
2087
|
afterHandleHooks: this.afterHandleHooks,
|
|
1695
2088
|
onErrorHooks: this.onErrorHooks,
|
|
@@ -1713,15 +2106,10 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
1713
2106
|
lanes,
|
|
1714
2107
|
routeAssurance,
|
|
1715
2108
|
} = compiled
|
|
2109
|
+
if (idempotent !== undefined && this.idempotencyRuntime !== undefined) {
|
|
2110
|
+
handler = this.idempotencyRuntime.wrapHandler(handler)
|
|
2111
|
+
}
|
|
1716
2112
|
const { bare, fusedQuery, fusedBody } = lanes
|
|
1717
|
-
const fusedBodyRunner = fusedBody
|
|
1718
|
-
? this.buildFusedBodyRunner(
|
|
1719
|
-
handler as unknown as InternalHandler,
|
|
1720
|
-
schema?.body as StandardSchemaV1,
|
|
1721
|
-
hasDecorations ? routeDecorations : undefined,
|
|
1722
|
-
bodyLimit ?? UNLIMITED_BODY_BYTES,
|
|
1723
|
-
)
|
|
1724
|
-
: undefined
|
|
1725
2113
|
// Fused lifecycle lanes: derive + before (with or without an after), and body + the same shape.
|
|
1726
2114
|
// The lane selectors in `selectRouteLanes` are exhaustive about the lifecycleHookLane /
|
|
1727
2115
|
// body-derive-before-after eligibility, so we only need to check the lane and the hook counts.
|
|
@@ -1729,6 +2117,46 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
1729
2117
|
const isFusedLifecycle = lifecycleHookLane !== undefined && this.derives.length === 1
|
|
1730
2118
|
const isFusedBodyLifecycle =
|
|
1731
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
|
|
1732
2160
|
const fusedWeb =
|
|
1733
2161
|
bare && this.aroundHooks.length === 0
|
|
1734
2162
|
? this.buildFusedWeb(
|
|
@@ -1777,6 +2205,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
1777
2205
|
schema,
|
|
1778
2206
|
handler: handler as unknown as InternalHandler,
|
|
1779
2207
|
derives: this.derives,
|
|
2208
|
+
authStages: compiled.authStages,
|
|
1780
2209
|
beforeHandle: this.beforeHandleHooks,
|
|
1781
2210
|
afterHandle: this.afterHandleHooks,
|
|
1782
2211
|
onError: this.onErrorHooks,
|
|
@@ -1805,6 +2234,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
1805
2234
|
ledgered,
|
|
1806
2235
|
responseContract,
|
|
1807
2236
|
derives: [...this.derives],
|
|
2237
|
+
authStages: [...compiled.authStages],
|
|
1808
2238
|
decorations: routeDecorations,
|
|
1809
2239
|
hasDecorations,
|
|
1810
2240
|
beforeHandle: [...this.beforeHandleHooks],
|
|
@@ -1819,6 +2249,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
1819
2249
|
method,
|
|
1820
2250
|
path,
|
|
1821
2251
|
schema,
|
|
2252
|
+
...(responseContract === undefined
|
|
2253
|
+
? {}
|
|
2254
|
+
: { responseContract: responseContract.runtime.mode }),
|
|
1822
2255
|
...(capabilities.length > 0 ? { capabilities } : {}),
|
|
1823
2256
|
...(schema?.family === true ? { family: true } : {}),
|
|
1824
2257
|
}
|
|
@@ -1867,10 +2300,11 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
1867
2300
|
this.assertConfigurable("responseObserver()")
|
|
1868
2301
|
const methods = runtime.install({
|
|
1869
2302
|
assertConfigurable: (operation) => this.assertConfigurable(operation),
|
|
1870
|
-
addResponseHook: (web, node) => {
|
|
2303
|
+
addResponseHook: (web, node, headerOnly = false) => {
|
|
1871
2304
|
this.onResponseHooks.push(web)
|
|
1872
2305
|
this.onNodeResponseHooks.push(node)
|
|
1873
2306
|
if (node === undefined) this.nodeResponseHooksComplete = false
|
|
2307
|
+
if (!headerOnly) this.bunNativeResponseHeadersOnly = false
|
|
1874
2308
|
},
|
|
1875
2309
|
enableResponseBodyTagging: () => {
|
|
1876
2310
|
if (this.responseBodyTag === undefined) {
|
|
@@ -1897,6 +2331,12 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
1897
2331
|
this.mcpRuntime = runtime
|
|
1898
2332
|
}
|
|
1899
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
|
+
|
|
1900
2340
|
/** @internal Symbol-keyed install seam for the `nodeDirect()` plugin. Off the public typed surface. */
|
|
1901
2341
|
[INSTALL_NODE_DIRECT](runtime: NodeOutcomeRuntime): void {
|
|
1902
2342
|
this.assertConfigurable("nodeDirect()")
|
|
@@ -1915,6 +2355,41 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
1915
2355
|
this.wsRuntime = runtime
|
|
1916
2356
|
}
|
|
1917
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
|
+
|
|
1918
2393
|
/**
|
|
1919
2394
|
* Merge another server's routes into this one - the composition escape hatch for large apps.
|
|
1920
2395
|
*
|
|
@@ -1943,6 +2418,19 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
1943
2418
|
other: Server<R2, Ctx2, HookOutput2>,
|
|
1944
2419
|
): Server<R & R2, Ctx, HookOutput> {
|
|
1945
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
|
+
}
|
|
1946
2434
|
const source = other as unknown as Server<Registry, EmptyContext>
|
|
1947
2435
|
if (source.wsRouteCount > 0) {
|
|
1948
2436
|
throw new RouteConfigError(
|
|
@@ -1950,83 +2438,190 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
1950
2438
|
"merge() does not carry WebSocket routes - register .ws() routes on the parent server",
|
|
1951
2439
|
)
|
|
1952
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 {
|
|
1953
2551
|
const sourceRoutes = source.catalog.entries()
|
|
1954
2552
|
// Hooks and global assurance stay LOCAL to a group that has routes; a route-less group is a
|
|
1955
2553
|
// middleware bundle whose hooks can only mean app-wide intent, so it keeps the global append.
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
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
|
|
1959
2559
|
// enforcement for parent routes the group's hooks do not see.
|
|
1960
2560
|
const foldedAssurance =
|
|
1961
|
-
scoped &&
|
|
2561
|
+
scoped && ownGlobal.length > 0
|
|
1962
2562
|
? (route: CatalogRoute): CatalogRoute => ({
|
|
1963
2563
|
...route,
|
|
1964
|
-
assurance: [...route.assurance, ...
|
|
2564
|
+
assurance: [...route.assurance, ...ownGlobal],
|
|
1965
2565
|
})
|
|
1966
2566
|
: (route: CatalogRoute): CatalogRoute => route
|
|
1967
2567
|
this.catalog.addBatch(
|
|
1968
2568
|
sourceRoutes.map((route) => this.bindFusedRuntime(foldedAssurance(route))),
|
|
1969
2569
|
)
|
|
1970
2570
|
// Resolved idempotency/ledger route entries carry their own store/sink configuration, while the
|
|
1971
|
-
// runtime object supplies the generic execution machinery.
|
|
1972
|
-
//
|
|
1973
|
-
//
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
const scope = new RouteCatalog()
|
|
1987
|
-
scope.addBatch(sourceRoutes)
|
|
1988
|
-
this.onRequestHooks.push(
|
|
1989
|
-
...source.onRequestHooks.map(
|
|
1990
|
-
(hook): RawOnRequest =>
|
|
1991
|
-
(req, platform) =>
|
|
1992
|
-
scope.find(req.method, pathnameOf(req.url)).found ? hook(req, platform) : undefined,
|
|
1993
|
-
),
|
|
1994
|
-
)
|
|
1995
|
-
// An `undefined` slot marks an unpaired hook (position-aligned with `onRequestHooks`) and
|
|
1996
|
-
// must stay `undefined` - wrapping it would fabricate a Node twin that never existed.
|
|
1997
|
-
this.onNodeRequestHooks.push(
|
|
1998
|
-
...source.onNodeRequestHooks.map((hook): NodeRequestHook | undefined =>
|
|
1999
|
-
hook === undefined
|
|
2000
|
-
? undefined
|
|
2001
|
-
: (req, platform) =>
|
|
2002
|
-
scope.find(req.method, pathnameOf(req.url)).found ? hook(req, platform) : undefined,
|
|
2003
|
-
),
|
|
2004
|
-
)
|
|
2005
|
-
} else {
|
|
2006
|
-
this.onRequestHooks.push(...source.onRequestHooks)
|
|
2007
|
-
this.onNodeRequestHooks.push(...source.onNodeRequestHooks)
|
|
2008
|
-
}
|
|
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))
|
|
2009
2586
|
this.nodeRequestHooksComplete &&= source.nodeRequestHooksComplete
|
|
2010
|
-
|
|
2011
|
-
//
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
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
|
+
)
|
|
2018
2611
|
this.nodeResponseHooksComplete &&= source.nodeResponseHooksComplete
|
|
2612
|
+
this.bunNativeResponseHeadersOnly &&= source.bunNativeResponseHeadersOnly
|
|
2019
2613
|
this.hasRawNodeResponseHook ||= source.hasRawNodeResponseHook
|
|
2020
|
-
this.onResponseFinalizedHooks.push(...source.onResponseFinalizedHooks)
|
|
2021
2614
|
if (source.responseBodyTag !== undefined) {
|
|
2022
2615
|
const owner = this.enableResponseBodyTagging()
|
|
2023
2616
|
this.responseBodyOwners.add(source.responseBodyTag)
|
|
2024
2617
|
source.responseBodyOwners.add(owner)
|
|
2025
2618
|
}
|
|
2026
|
-
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)
|
|
2027
2623
|
this.mcpResourceList.push(...source.mcpResourceList)
|
|
2028
2624
|
this.mcpPromptList.push(...source.mcpPromptList)
|
|
2029
|
-
return this as unknown as Server<R & R2, Ctx, HookOutput>
|
|
2030
2625
|
}
|
|
2031
2626
|
|
|
2032
2627
|
/** A fused renderer closes over runtime services to keep its seven-argument JSC fast path. Merging
|
|
@@ -2039,9 +2634,13 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
2039
2634
|
const lane = entry.execution.fusedLane
|
|
2040
2635
|
// Type-erase the lane so each `===` branch sees the full union, not the narrowed remainder.
|
|
2041
2636
|
const laneName: string | undefined = lane
|
|
2042
|
-
// Body+lifecycle routes have a fused Web renderer,
|
|
2043
|
-
//
|
|
2044
|
-
//
|
|
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]
|
|
2045
2644
|
const fusedBody =
|
|
2046
2645
|
laneName === "body"
|
|
2047
2646
|
? this.buildFusedBodyRunner(
|
|
@@ -2050,10 +2649,49 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
2050
2649
|
entry.hasDecorations ? entry.decorations : undefined,
|
|
2051
2650
|
entry.bodyLimit ?? UNLIMITED_BODY_BYTES,
|
|
2052
2651
|
)
|
|
2053
|
-
: undefined
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
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
|
|
2057
2695
|
const fusedWeb =
|
|
2058
2696
|
laneName === "body"
|
|
2059
2697
|
? this.buildFusedBodyWeb(fusedBody as FusedBodyRunner)
|
|
@@ -2143,6 +2781,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
2143
2781
|
* Bun/Node/Deno omit it (then `c.env` is `undefined` and `c.waitUntil` runs fire-and-forget).
|
|
2144
2782
|
*/
|
|
2145
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)
|
|
2146
2787
|
// An edge deployment whose only ingress is this method declares its requests runtime-framed at
|
|
2147
2788
|
// construction; everything else stays on the delivered-byte check (see `trustBodyFraming`).
|
|
2148
2789
|
if (this.trustBodyFraming) markTrustedBodyFraming(req)
|
|
@@ -2198,19 +2839,25 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
2198
2839
|
source: RequestSource,
|
|
2199
2840
|
entry: RouteEntry,
|
|
2200
2841
|
params: Record<string, string>,
|
|
2842
|
+
platform: Platform,
|
|
2201
2843
|
): MaybePromise<Response> {
|
|
2202
|
-
if (this.capacityGate === undefined)
|
|
2203
|
-
|
|
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
|
+
)
|
|
2204
2850
|
}
|
|
2205
2851
|
|
|
2206
2852
|
private fetchMatchedInner(
|
|
2207
2853
|
source: RequestSource,
|
|
2208
2854
|
entry: RouteEntry,
|
|
2209
2855
|
params: Record<string, string>,
|
|
2856
|
+
platform: Platform,
|
|
2210
2857
|
): MaybePromise<Response> {
|
|
2211
2858
|
const outcome = this.runMatched(
|
|
2212
2859
|
source,
|
|
2213
|
-
|
|
2860
|
+
platform,
|
|
2214
2861
|
entry,
|
|
2215
2862
|
params,
|
|
2216
2863
|
undefined,
|
|
@@ -2289,18 +2936,30 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
2289
2936
|
|
|
2290
2937
|
const parts = source.urlParts ?? urlPartsOf(source.url)
|
|
2291
2938
|
const match = this.catalog.find(source.method, parts.pathname)
|
|
2292
|
-
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
|
+
}
|
|
2293
2955
|
|
|
2956
|
+
private firstMountUnder(
|
|
2957
|
+
pathname: string,
|
|
2958
|
+
beforeRoutes: boolean,
|
|
2959
|
+
): FetchMount<EnvOf<Ctx>> | undefined {
|
|
2294
2960
|
for (const mount of this.fetchMounts) {
|
|
2295
|
-
if (
|
|
2296
|
-
|
|
2297
|
-
return typeof candidate === "function"
|
|
2298
|
-
? {
|
|
2299
|
-
handler: candidate as NativeMountHandler,
|
|
2300
|
-
path: mount.path,
|
|
2301
|
-
stripPrefix: mount.stripPrefix,
|
|
2302
|
-
}
|
|
2303
|
-
: undefined
|
|
2961
|
+
if (mount.beforeRoutes === beforeRoutes && underMountPrefix(pathname, mount.path))
|
|
2962
|
+
return mount
|
|
2304
2963
|
}
|
|
2305
2964
|
return undefined
|
|
2306
2965
|
}
|
|
@@ -2468,6 +3127,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
2468
3127
|
const context: NodeResponseContext = {
|
|
2469
3128
|
status: outcome.status,
|
|
2470
3129
|
headers,
|
|
3130
|
+
headersAreLowercase: !this.hasRawNodeResponseHook && lowercaseKeys,
|
|
2471
3131
|
cookies: outcome.kind === "json" ? outcome.cookies : undefined,
|
|
2472
3132
|
body: outcome.body,
|
|
2473
3133
|
}
|
|
@@ -2609,8 +3269,17 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
2609
3269
|
req: Request,
|
|
2610
3270
|
platform?: Platform<EnvOf<Ctx>>,
|
|
2611
3271
|
): MaybePromise<WebSocketUpgradeOutcome> {
|
|
2612
|
-
|
|
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
|
|
2613
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)
|
|
2614
3283
|
|
|
2615
3284
|
const timeoutMs =
|
|
2616
3285
|
this.wsUpgradeTimeoutMs === 0
|
|
@@ -2708,7 +3377,61 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
2708
3377
|
}
|
|
2709
3378
|
}
|
|
2710
3379
|
|
|
2711
|
-
|
|
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)
|
|
2712
3435
|
let hooked: MaybePromise<Request | Response>
|
|
2713
3436
|
try {
|
|
2714
3437
|
hooked = this.runWebSocketRequestHooks(req, platform)
|
|
@@ -2718,13 +3441,13 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
2718
3441
|
if (!(hooked instanceof Promise)) {
|
|
2719
3442
|
return hooked instanceof Response
|
|
2720
3443
|
? { kind: "reject", response: hooked }
|
|
2721
|
-
:
|
|
3444
|
+
: resolveWithDeadline(hooked)
|
|
2722
3445
|
}
|
|
2723
3446
|
const pending = hooked.then(
|
|
2724
3447
|
(result) =>
|
|
2725
3448
|
result instanceof Response
|
|
2726
3449
|
? { kind: "reject" as const, response: result }
|
|
2727
|
-
:
|
|
3450
|
+
: resolveWithDeadline(result),
|
|
2728
3451
|
() => ({ kind: "reject" as const, response: jsonError(500, "internal_error") }),
|
|
2729
3452
|
)
|
|
2730
3453
|
return bounded(
|
|
@@ -2744,7 +3467,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
2744
3467
|
if (o.kind === "pass")
|
|
2745
3468
|
return this.fetch(req, bunPeerPlatform(server, req) as Platform<EnvOf<Ctx>>)
|
|
2746
3469
|
if (o.kind === "reject") return o.response
|
|
2747
|
-
return server.upgrade(req, {
|
|
3470
|
+
return server.upgrade(req, {
|
|
3471
|
+
data: { handler: o.handler, data: o.data, pubsub: o.pubsub },
|
|
3472
|
+
})
|
|
2748
3473
|
? undefined
|
|
2749
3474
|
: jsonError(426, "upgrade_required")
|
|
2750
3475
|
}
|
|
@@ -2766,15 +3491,27 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
2766
3491
|
* whose `clientIp` is the derived caller. Only called when a trust declaration is configured. */
|
|
2767
3492
|
private deriveClientIp(
|
|
2768
3493
|
source: RequestSource,
|
|
2769
|
-
platform: Platform | undefined,
|
|
2770
|
-
): Platform | undefined {
|
|
3494
|
+
platform: Platform<EnvOf<Ctx>> | undefined,
|
|
3495
|
+
): Platform<EnvOf<Ctx>> | undefined {
|
|
2771
3496
|
const derived = resolveClientIp(platform?.clientIp, requestOf(source), this.clientIpTrust)
|
|
2772
3497
|
return { ...platform, clientIp: derived }
|
|
2773
3498
|
}
|
|
2774
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
|
+
|
|
2775
3512
|
private dispatch<T>(
|
|
2776
3513
|
source: RequestSource,
|
|
2777
|
-
platform: Platform | undefined,
|
|
3514
|
+
platform: Platform<EnvOf<Ctx>> | undefined,
|
|
2778
3515
|
finalize: (result: unknown, set: CtxSet) => T,
|
|
2779
3516
|
wrapResponse: (response: Response | ResponseResult) => T,
|
|
2780
3517
|
onTimeout: () => T,
|
|
@@ -2785,11 +3522,14 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
2785
3522
|
// Resolve the trust declaration into the platform's `clientIp` ONCE, here at the shared funnel, so
|
|
2786
3523
|
// `c.clientIp` (and every hook/derive downstream) sees the derived caller. No config ⇒ the raw
|
|
2787
3524
|
// socket peer the adapter supplied passes through untouched (a one-property no-op on the hot path).
|
|
2788
|
-
|
|
2789
|
-
|
|
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)
|
|
2790
3528
|
// onRequest hooks may be async, so a hooked app takes the async path; with no hooks (the common
|
|
2791
3529
|
// case) routing stays synchronous, letting a bare route resolve with no lifecycle promise at all.
|
|
2792
|
-
|
|
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)) {
|
|
2793
3533
|
return this.routeAndRun(source, resolved, finalize, wrapResponse, onTimeout, webFast)
|
|
2794
3534
|
}
|
|
2795
3535
|
if (!webFast && this.canUseNodeRequestHooks()) {
|
|
@@ -2802,7 +3542,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
2802
3542
|
* Web `Request`; arbitrary request rewrites and full Web hooks use {@link runWithOnRequest}. */
|
|
2803
3543
|
private runWithNodeRequest<T>(
|
|
2804
3544
|
source: RequestSource,
|
|
2805
|
-
platform: Platform | undefined,
|
|
3545
|
+
platform: Platform<EnvOf<Ctx>> | undefined,
|
|
2806
3546
|
finalize: (result: unknown, set: CtxSet) => T,
|
|
2807
3547
|
wrapResponse: (response: Response | ResponseResult) => T,
|
|
2808
3548
|
onTimeout: () => T,
|
|
@@ -2836,7 +3576,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
2836
3576
|
nextIndex: number,
|
|
2837
3577
|
request: NodeRequestContext,
|
|
2838
3578
|
source: RequestSource,
|
|
2839
|
-
platform: Platform | undefined,
|
|
3579
|
+
platform: Platform<EnvOf<Ctx>> | undefined,
|
|
2840
3580
|
finalize: (result: unknown, set: CtxSet) => T,
|
|
2841
3581
|
wrapResponse: (response: Response | ResponseResult) => T,
|
|
2842
3582
|
onTimeout: () => T,
|
|
@@ -2864,7 +3604,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
2864
3604
|
*/
|
|
2865
3605
|
private runWithOnRequest<T>(
|
|
2866
3606
|
source: RequestSource,
|
|
2867
|
-
platform: Platform | undefined,
|
|
3607
|
+
platform: Platform<EnvOf<Ctx>> | undefined,
|
|
2868
3608
|
finalize: (result: unknown, set: CtxSet) => T,
|
|
2869
3609
|
wrapResponse: (response: Response | ResponseResult) => T,
|
|
2870
3610
|
onTimeout: () => T,
|
|
@@ -2878,7 +3618,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
2878
3618
|
if (source !== originalRequest) this.responseSources.set(source as object, originalRequest)
|
|
2879
3619
|
let current: RequestSource = source
|
|
2880
3620
|
for (let i = 0; i < hooks.length; i++) {
|
|
2881
|
-
const
|
|
3621
|
+
const seen = requestOf(current)
|
|
3622
|
+
const outcome = (hooks[i] as RawOnRequest)(seen, platform)
|
|
2882
3623
|
if (outcome instanceof Promise) {
|
|
2883
3624
|
return outcome.then((early) =>
|
|
2884
3625
|
this.continueOnRequest(
|
|
@@ -2895,6 +3636,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
2895
3636
|
)
|
|
2896
3637
|
}
|
|
2897
3638
|
if (outcome instanceof Request) {
|
|
3639
|
+
if (outcome !== seen) recordRequestReplacement(outcome, seen)
|
|
2898
3640
|
current = outcome
|
|
2899
3641
|
if (outcome !== originalRequest) this.responseRequests.set(originalRequest, outcome)
|
|
2900
3642
|
continue
|
|
@@ -2911,24 +3653,27 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
2911
3653
|
nextIndex: number,
|
|
2912
3654
|
originalRequest: Request,
|
|
2913
3655
|
sourceAtAwait: RequestSource,
|
|
2914
|
-
platform: Platform | undefined,
|
|
3656
|
+
platform: Platform<EnvOf<Ctx>> | undefined,
|
|
2915
3657
|
finalize: (result: unknown, set: CtxSet) => T,
|
|
2916
3658
|
wrapResponse: (response: Response | ResponseResult) => T,
|
|
2917
3659
|
onTimeout: () => T,
|
|
2918
3660
|
webFast: boolean,
|
|
2919
3661
|
): Promise<T> {
|
|
2920
3662
|
let current = sourceAtAwait
|
|
3663
|
+
let seen = requestOf(sourceAtAwait)
|
|
2921
3664
|
let early = first
|
|
2922
3665
|
let index = nextIndex
|
|
2923
3666
|
for (;;) {
|
|
2924
3667
|
if (early instanceof Request) {
|
|
3668
|
+
if (early !== seen) recordRequestReplacement(early, seen)
|
|
2925
3669
|
current = early
|
|
2926
3670
|
if (early !== originalRequest) this.responseRequests.set(originalRequest, early)
|
|
2927
3671
|
} else if (early !== undefined) {
|
|
2928
3672
|
return wrapResponse(early)
|
|
2929
3673
|
}
|
|
2930
3674
|
if (index >= this.onRequestHooks.length) break
|
|
2931
|
-
|
|
3675
|
+
seen = requestOf(current)
|
|
3676
|
+
const outcome = (this.onRequestHooks[index] as RawOnRequest)(seen, platform)
|
|
2932
3677
|
early = outcome instanceof Promise ? await outcome : outcome
|
|
2933
3678
|
index++
|
|
2934
3679
|
}
|
|
@@ -3005,7 +3750,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
3005
3750
|
*/
|
|
3006
3751
|
private routeAndRun<T>(
|
|
3007
3752
|
source: RequestSource,
|
|
3008
|
-
platform: Platform | undefined,
|
|
3753
|
+
platform: Platform<EnvOf<Ctx>> | undefined,
|
|
3009
3754
|
finalize: (result: unknown, set: CtxSet) => T,
|
|
3010
3755
|
wrapResponse: (response: Response | ResponseResult) => T,
|
|
3011
3756
|
onTimeout: () => T,
|
|
@@ -3049,9 +3794,21 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
3049
3794
|
search = searchStart === -1 ? "" : rawUrl.slice(searchStart, searchEnd)
|
|
3050
3795
|
}
|
|
3051
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
|
+
|
|
3052
3809
|
const match = this.catalog.find(source.method, pathname)
|
|
3053
3810
|
if (!match.found) {
|
|
3054
|
-
const mounted = this.fetchMount(pathname, source, platform)
|
|
3811
|
+
const mounted = this.fetchMount(pathname, source, platform, false)
|
|
3055
3812
|
if (mounted !== undefined) {
|
|
3056
3813
|
return mounted instanceof Promise
|
|
3057
3814
|
? mounted.then((response) => wrapResponse(response))
|
|
@@ -3064,7 +3821,10 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
3064
3821
|
plainError(405, "method_not_allowed", { allow: match.allowed.join(", ") }),
|
|
3065
3822
|
)
|
|
3066
3823
|
}
|
|
3067
|
-
|
|
3824
|
+
const lane = this.notFoundLane
|
|
3825
|
+
return lane === undefined
|
|
3826
|
+
? wrapResponse(plainError(404, "not_found"))
|
|
3827
|
+
: lane(this, source, pathname, platform, wrapResponse, onTimeout)
|
|
3068
3828
|
}
|
|
3069
3829
|
|
|
3070
3830
|
// Inspect only captured values for escapes. Scanning the full pathname repeated work the router
|
|
@@ -3090,17 +3850,48 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
3090
3850
|
private fetchMount(
|
|
3091
3851
|
pathname: string,
|
|
3092
3852
|
source: RequestSource,
|
|
3093
|
-
platform: Platform | undefined,
|
|
3853
|
+
platform: Platform<EnvOf<Ctx>> | undefined,
|
|
3854
|
+
beforeRoutes: boolean,
|
|
3094
3855
|
): MaybePromise<Response> | undefined {
|
|
3095
|
-
|
|
3096
|
-
|
|
3097
|
-
|
|
3098
|
-
|
|
3099
|
-
|
|
3100
|
-
|
|
3101
|
-
|
|
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
|
|
3102
3863
|
}
|
|
3103
|
-
|
|
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)
|
|
3104
3895
|
}
|
|
3105
3896
|
|
|
3106
3897
|
/** Run a route that has already been matched by the runtime or Nifra's portable router. */
|
|
@@ -3124,8 +3915,12 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
3124
3915
|
// handler can reach `c.req`; that lane already enforces `entry.bodyLimit`. Installing the full
|
|
3125
3916
|
// direct-reader cap on `c.req` here would allocate bound readers, closures, and a stream wrapper
|
|
3126
3917
|
// for a body that is already consumed. Keep the lazy transport cap for raw-body routes, where a
|
|
3127
|
-
// user read is the only framework-owned body boundary
|
|
3128
|
-
|
|
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
|
+
) {
|
|
3129
3924
|
const method = source.method
|
|
3130
3925
|
if (method !== "GET" && method !== "HEAD") markTransportCap(source, entry.bodyLimit)
|
|
3131
3926
|
}
|
|
@@ -3639,6 +4434,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
3639
4434
|
finish,
|
|
3640
4435
|
wrapResponse,
|
|
3641
4436
|
(err) => logError(err, ctx, finalize, wrapResponse),
|
|
4437
|
+
bodySchema,
|
|
3642
4438
|
)
|
|
3643
4439
|
}
|
|
3644
4440
|
}
|
|
@@ -4208,24 +5004,455 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
4208
5004
|
onParsed,
|
|
4209
5005
|
(response) => this.wrapWebResponse(response),
|
|
4210
5006
|
(err) => Promise.resolve(logError(err, ctx)),
|
|
5007
|
+
bodySchema,
|
|
4211
5008
|
) as MaybePromise<Response>
|
|
4212
5009
|
}
|
|
4213
5010
|
}
|
|
4214
5011
|
|
|
4215
|
-
|
|
4216
|
-
|
|
4217
|
-
|
|
4218
|
-
|
|
4219
|
-
|
|
4220
|
-
|
|
4221
|
-
|
|
4222
|
-
|
|
4223
|
-
|
|
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
|
+
|
|
5443
|
+
private bareError<T>(
|
|
5444
|
+
err: unknown,
|
|
5445
|
+
ctx: RawContext,
|
|
5446
|
+
finalize: (result: unknown, set: CtxSet, ctx: RawContext) => T,
|
|
5447
|
+
wrapResponse: (response: Response | ResponseResult) => T,
|
|
5448
|
+
): T {
|
|
5449
|
+
// The thrown-value contract lives in `bare-error-lane.ts`, one source of truth for both this
|
|
5450
|
+
// lane and the fused body runner. `responseSet` and the logger are injected because they reach
|
|
5451
|
+
// into class state; the closure is allocated only on the throw path, never on the hot lane.
|
|
4224
5452
|
return renderBareError(
|
|
4225
5453
|
err,
|
|
4226
5454
|
ctx,
|
|
4227
5455
|
finalize,
|
|
4228
|
-
wrapResponse,
|
|
4229
5456
|
responseSet,
|
|
4230
5457
|
(e, c) => this.logRequestError(e, c),
|
|
4231
5458
|
() => this.internalErrorResponse(wrapResponse),
|
|
@@ -4275,19 +5502,17 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
4275
5502
|
// @ts-expect-error TS6133 -- invoked structurally by the general route program
|
|
4276
5503
|
private validateProgramStage(
|
|
4277
5504
|
entry: RouteEntry,
|
|
4278
|
-
stage: Extract<RouteProgramStage, { kind:
|
|
5505
|
+
stage: Extract<RouteProgramStage, { kind: ProgramValidationKind }>,
|
|
4279
5506
|
source: RequestSource,
|
|
4280
5507
|
ctx: RawContext,
|
|
4281
5508
|
): MaybePromise<Response | ResponseResult | undefined> {
|
|
5509
|
+
if (stage.kind === "body") return this.readProgramBody(entry, source, ctx)
|
|
4282
5510
|
const input =
|
|
4283
5511
|
stage.kind === "headers"
|
|
4284
5512
|
? headerObjectOf(source.headers)
|
|
4285
|
-
: stage.kind === "
|
|
4286
|
-
? ctx
|
|
4287
|
-
: stage.kind
|
|
4288
|
-
? queryObjectOf(ctx[CONTEXT_SEARCH])
|
|
4289
|
-
: undefined
|
|
4290
|
-
if (stage.kind === "body") return this.readProgramBody(entry, source, ctx)
|
|
5513
|
+
: stage.kind === "query"
|
|
5514
|
+
? queryObjectOf(ctx[CONTEXT_SEARCH])
|
|
5515
|
+
: ctx[stage.kind]
|
|
4291
5516
|
const validation = stage.schema["~standard"].validate(input)
|
|
4292
5517
|
return validation instanceof Promise
|
|
4293
5518
|
? validation.then((result) => this.applyLifecycleValidation(entry, result, ctx, stage.kind))
|
|
@@ -4362,6 +5587,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
4362
5587
|
(parsed) => this.finishBodyOnly(entry, parsed, ctx, finalize, wrapResponse),
|
|
4363
5588
|
wrapResponse,
|
|
4364
5589
|
(err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse),
|
|
5590
|
+
entry.schema?.body,
|
|
4365
5591
|
)
|
|
4366
5592
|
}
|
|
4367
5593
|
|
|
@@ -4726,7 +5952,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
4726
5952
|
try {
|
|
4727
5953
|
const contract = entry.responseContract
|
|
4728
5954
|
if (contract === undefined) return finalize(result, responseSet(ctx))
|
|
4729
|
-
const checked = contract.runtime.check(contract.
|
|
5955
|
+
const checked = contract.runtime.check(contract.definition, result, ctx)
|
|
4730
5956
|
if (checked instanceof Promise) {
|
|
4731
5957
|
return checked.then(
|
|
4732
5958
|
(outcome) => this.finishContractOutcome(ctx, finalize, wrapResponse, outcome),
|
|
@@ -4780,13 +6006,15 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
4780
6006
|
wrapResponse: (response: Response | ResponseResult) => T,
|
|
4781
6007
|
): MaybePromise<T> {
|
|
4782
6008
|
// A *thrown* Response is deliberate control flow, not an error - a guard throws a redirect/401,
|
|
4783
|
-
// an action throws an error page.
|
|
4784
|
-
//
|
|
4785
|
-
//
|
|
4786
|
-
|
|
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))
|
|
4787
6014
|
// Same rule for a thrown `status(...)`, but through `finalize` rather than `wrapResponse`: the
|
|
4788
6015
|
// value is still plain data, so the ordinary JSON lane renders it and no `Response` is built.
|
|
4789
|
-
if (isResponseResult(err))
|
|
6016
|
+
if (isResponseResult(err))
|
|
6017
|
+
return this.finishLifecycleContract(entry, ctx, finalize, wrapResponse, err)
|
|
4790
6018
|
if (entry.onError.length === 0) {
|
|
4791
6019
|
// Never crash the server or leak internals. The client gets a flat 500; the detail goes to the
|
|
4792
6020
|
// (redacting) logger. Body-read failures and around-hook failures land here too.
|
|
@@ -4846,6 +6074,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
4846
6074
|
validate,
|
|
4847
6075
|
(response) => response,
|
|
4848
6076
|
(error) => Promise.reject(error),
|
|
6077
|
+
bodySchema,
|
|
4849
6078
|
)
|
|
4850
6079
|
}
|
|
4851
6080
|
|
|
@@ -4855,13 +6084,10 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
4855
6084
|
entry: RouteEntry,
|
|
4856
6085
|
result: StandardResult<unknown>,
|
|
4857
6086
|
ctx: RawContext,
|
|
4858
|
-
kind:
|
|
6087
|
+
kind: ProgramValidationKind,
|
|
4859
6088
|
): MaybePromise<Response | ResponseResult | undefined> {
|
|
4860
6089
|
const assign = (value: unknown): void => {
|
|
4861
|
-
|
|
4862
|
-
else if (kind === "query") ctx.query = value
|
|
4863
|
-
else if (kind === "headers") ctx.headers = value as Record<string, string>
|
|
4864
|
-
else ctx.params = value as Record<string, string>
|
|
6090
|
+
;(ctx as unknown as Record<ProgramValidationKind, unknown>)[kind] = value
|
|
4865
6091
|
}
|
|
4866
6092
|
if (result.issues === undefined) {
|
|
4867
6093
|
assign(result.value)
|
|
@@ -4880,22 +6106,14 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
4880
6106
|
|
|
4881
6107
|
private finishLifecycleValidationRecovery(
|
|
4882
6108
|
entry: RouteEntry,
|
|
4883
|
-
kind:
|
|
6109
|
+
kind: ProgramValidationKind,
|
|
4884
6110
|
issues: ReadonlyArray<StandardIssue>,
|
|
4885
6111
|
recovery: unknown,
|
|
4886
6112
|
assign: (value: unknown) => void,
|
|
4887
6113
|
): MaybePromise<Response | ResponseResult | undefined> {
|
|
4888
6114
|
if (recovery === undefined) return plainValidationError(issues)
|
|
4889
6115
|
if (recovery instanceof Response) return recovery
|
|
4890
|
-
const
|
|
4891
|
-
kind === "body"
|
|
4892
|
-
? entry.schema?.body
|
|
4893
|
-
: kind === "query"
|
|
4894
|
-
? entry.schema?.query
|
|
4895
|
-
: kind === "params"
|
|
4896
|
-
? entry.schema?.params
|
|
4897
|
-
: entry.schema?.headers
|
|
4898
|
-
const retried = schema!["~standard"].validate(recovery)
|
|
6116
|
+
const retried = entry.schema![kind]!["~standard"].validate(recovery)
|
|
4899
6117
|
if (retried instanceof Promise) {
|
|
4900
6118
|
return retried.then((settled) => {
|
|
4901
6119
|
if (settled.issues !== undefined) return plainValidationError(settled.issues)
|
|
@@ -4916,6 +6134,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
4916
6134
|
fused: FusedWebRunner | undefined,
|
|
4917
6135
|
signal: AbortSignal | undefined,
|
|
4918
6136
|
budget: RequestBudget | undefined,
|
|
6137
|
+
peer: PeerPlatform,
|
|
6138
|
+
fallback: BunNativeHandler,
|
|
4919
6139
|
): BunNativeHandler {
|
|
4920
6140
|
// This callback is reached only from Bun's compiled native route table. Bun has already parsed
|
|
4921
6141
|
// the HTTP framing, so the JSON lane may retain its native fused `json()` parse without weakening
|
|
@@ -4924,7 +6144,10 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
4924
6144
|
// The fused native lane bypasses `runMatched`, so the route's transport byte cap must be
|
|
4925
6145
|
// marked here too - otherwise a Bun `listen()` fused route would leave direct `c.req` body
|
|
4926
6146
|
// reads uncapped. The non-fused branches go through `fetchMatched` -> `runMatched`, which marks.
|
|
4927
|
-
const bodyLimit =
|
|
6147
|
+
const bodyLimit =
|
|
6148
|
+
entry.schema?.body === undefined || entry.program.authBeforeValidation
|
|
6149
|
+
? entry.bodyLimit
|
|
6150
|
+
: undefined
|
|
4928
6151
|
const inner = fused
|
|
4929
6152
|
const capped: FusedWebRunner | undefined =
|
|
4930
6153
|
inner === undefined || bodyLimit === undefined
|
|
@@ -4934,68 +6157,90 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
4934
6157
|
if (method !== "GET" && method !== "HEAD") markTransportCap(source, bodyLimit)
|
|
4935
6158
|
return inner(source, params, search, fusedSignal, fusedBudget, platform, nativeContext)
|
|
4936
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
|
+
}
|
|
4937
6174
|
if (paramNames.length === 0) {
|
|
4938
6175
|
if (capped === undefined) {
|
|
4939
6176
|
return (request) => {
|
|
4940
6177
|
markFramed(request)
|
|
4941
|
-
return this.fetchMatched(request, entry, EMPTY_PARAMS)
|
|
6178
|
+
return this.fetchMatched(request, entry, EMPTY_PARAMS, peer)
|
|
4942
6179
|
}
|
|
4943
6180
|
}
|
|
4944
6181
|
if (this.acceptInboundDeadlines) {
|
|
4945
6182
|
return (request) => {
|
|
4946
6183
|
markFramed(request)
|
|
4947
6184
|
return request.headers.get(NIFRA_DEADLINE_HEADER) !== null
|
|
4948
|
-
? this.fetchMatched(request, entry, EMPTY_PARAMS)
|
|
4949
|
-
: 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)
|
|
4950
6187
|
}
|
|
4951
6188
|
}
|
|
4952
6189
|
return (request) => {
|
|
4953
6190
|
markFramed(request)
|
|
4954
|
-
return
|
|
6191
|
+
return runNative(request, EMPTY_PARAMS)
|
|
4955
6192
|
}
|
|
4956
6193
|
}
|
|
4957
6194
|
|
|
4958
6195
|
const malformed =
|
|
4959
6196
|
paramNames.length === 1
|
|
4960
|
-
? (params: Record<string, string>) => params[paramNames[0]!]
|
|
4961
|
-
:
|
|
6197
|
+
? (params: Record<string, string>) => unroutedParam(params[paramNames[0]!])
|
|
6198
|
+
: hasUnroutedParam
|
|
4962
6199
|
if (capped === undefined) {
|
|
4963
|
-
return (request) => {
|
|
6200
|
+
return (request, server) => {
|
|
4964
6201
|
markFramed(request)
|
|
4965
6202
|
const params = (request as BunRequestWithParams).params ?? EMPTY_PARAMS
|
|
4966
|
-
if (malformed(params)) return
|
|
4967
|
-
return this.fetchMatched(request, entry, params)
|
|
6203
|
+
if (malformed(params)) return fallback(request, server)
|
|
6204
|
+
return this.fetchMatched(request, entry, params, peer)
|
|
4968
6205
|
}
|
|
4969
6206
|
}
|
|
4970
6207
|
if (this.acceptInboundDeadlines) {
|
|
4971
|
-
return (request) => {
|
|
6208
|
+
return (request, server) => {
|
|
4972
6209
|
markFramed(request)
|
|
4973
6210
|
const params = (request as BunRequestWithParams).params ?? EMPTY_PARAMS
|
|
4974
|
-
if (malformed(params)) return
|
|
6211
|
+
if (malformed(params)) return fallback(request, server)
|
|
4975
6212
|
return request.headers.get(NIFRA_DEADLINE_HEADER) !== null
|
|
4976
|
-
? this.fetchMatched(request, entry, params)
|
|
4977
|
-
: capped(request, params, undefined, signal!, budget!,
|
|
6213
|
+
? this.fetchMatched(request, entry, params, peer)
|
|
6214
|
+
: capped(request, params, undefined, signal!, budget!, peer, true)
|
|
4978
6215
|
}
|
|
4979
6216
|
}
|
|
4980
|
-
return (request) => {
|
|
6217
|
+
return (request, server) => {
|
|
4981
6218
|
markFramed(request)
|
|
4982
6219
|
const params = (request as BunRequestWithParams).params ?? EMPTY_PARAMS
|
|
4983
|
-
if (malformed(params)) return
|
|
4984
|
-
return
|
|
6220
|
+
if (malformed(params)) return fallback(request, server)
|
|
6221
|
+
return runNative(request, params)
|
|
4985
6222
|
}
|
|
4986
6223
|
}
|
|
4987
6224
|
|
|
4988
6225
|
/** Compile portable route registrations into Bun's native route table. Apps with request-rewrite
|
|
4989
6226
|
* hooks or WebSockets retain the single portable dispatcher because those features must run before
|
|
4990
|
-
* route selection/upgrade.
|
|
4991
|
-
*
|
|
4992
|
-
|
|
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 {
|
|
4993
6237
|
// A `clientIp` trust declaration must run the resolver in `dispatch`, which the fused native lane
|
|
4994
6238
|
// bypasses - so an app that declares trust routes through the fetch lane (where `c.clientIp`
|
|
4995
6239
|
// resolves) instead of Bun's native table. The allocation-free default keeps native fusion.
|
|
4996
6240
|
if (
|
|
4997
|
-
this.onRequestHooks.length > 0 ||
|
|
6241
|
+
(this.onRequestHooks.length > 0 && !this.bunNativeRequestHooksSafe) ||
|
|
4998
6242
|
this.wsRouteCount > 0 ||
|
|
6243
|
+
this.preRouteMountCount > 0 ||
|
|
4999
6244
|
this.clientIpTrust !== undefined
|
|
5000
6245
|
) {
|
|
5001
6246
|
return undefined
|
|
@@ -5004,7 +6249,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
5004
6249
|
const routes: BunNativeRoutes = Object.create(null) as BunNativeRoutes
|
|
5005
6250
|
const mayUseFusedNative =
|
|
5006
6251
|
this.requestTimeoutMs === 0 &&
|
|
5007
|
-
this.onResponseHooks.length === 0 &&
|
|
6252
|
+
(this.onResponseHooks.length === 0 || this.bunNativeResponseHeadersOnly) &&
|
|
5008
6253
|
this.onResponseFinalizedHooks.length === 0 &&
|
|
5009
6254
|
// The capacity gate must wrap every request; the fused lane bypasses fetchMatched, so enabling
|
|
5010
6255
|
// admission drops fusion (native matching stays) and routes through the gated matched lane.
|
|
@@ -5012,23 +6257,75 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
5012
6257
|
const unboundedSignal = mayUseFusedNative ? getNeverAbortSignal() : undefined
|
|
5013
6258
|
const unboundedBudget = mayUseFusedNative ? getUnboundedRequestBudget() : undefined
|
|
5014
6259
|
let count = 0
|
|
5015
|
-
|
|
5016
|
-
|
|
5017
|
-
|
|
5018
|
-
|
|
5019
|
-
|
|
5020
|
-
|
|
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
|
|
5021
6312
|
}
|
|
5022
6313
|
const paramNames = pattern.paramNames
|
|
5023
6314
|
const fused = mayUseFusedNative ? entry.execution.fusedWeb : undefined
|
|
5024
|
-
|
|
6315
|
+
tableAt(path)[method as Method] = this.compileBunNativeHandler(
|
|
5025
6316
|
entry,
|
|
5026
6317
|
paramNames,
|
|
5027
6318
|
fused,
|
|
5028
6319
|
unboundedSignal,
|
|
5029
6320
|
unboundedBudget,
|
|
6321
|
+
peer,
|
|
6322
|
+
fallback,
|
|
5030
6323
|
)
|
|
5031
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
|
+
}
|
|
5032
6329
|
}
|
|
5033
6330
|
if (count === 0) return undefined
|
|
5034
6331
|
// RFC 9110 §9.3.2: a GET route answers HEAD with identical status + headers (Bun strips the
|
|
@@ -5039,6 +6336,17 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
5039
6336
|
const methods = routes[path]!
|
|
5040
6337
|
if (methods.GET !== undefined) methods.HEAD ??= methods.GET
|
|
5041
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
|
+
}
|
|
5042
6350
|
return routes
|
|
5043
6351
|
}
|
|
5044
6352
|
|
|
@@ -5063,6 +6371,10 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
5063
6371
|
* connection cut mid-flight regardless of `requestTimeoutMs`. Apps with such endpoints must raise
|
|
5064
6372
|
* this above their slowest expected response, which is why it is a first-class option and not a
|
|
5065
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.
|
|
5066
6378
|
*/
|
|
5067
6379
|
listen(
|
|
5068
6380
|
port: number,
|
|
@@ -5070,6 +6382,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
5070
6382
|
readonly reusePort?: boolean
|
|
5071
6383
|
readonly hostname?: string
|
|
5072
6384
|
readonly idleTimeoutSec?: number
|
|
6385
|
+
readonly tls?: ListenTlsOptions
|
|
5073
6386
|
},
|
|
5074
6387
|
): RunningServer {
|
|
5075
6388
|
if (typeof Bun === "undefined") {
|
|
@@ -5097,34 +6410,63 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
5097
6410
|
// because wsRouteCount > 0 means ws() ran, and ws() requires the runtime at registration.
|
|
5098
6411
|
// Native pub/sub when the app has WS routes and none validate outbound frames: `ws.subscribe` and
|
|
5099
6412
|
// `app.publish` go through Bun's own (uWebSockets) broadcast instead of the JS registry loop.
|
|
5100
|
-
|
|
5101
|
-
|
|
5102
|
-
|
|
5103
|
-
|
|
5104
|
-
|
|
5105
|
-
|
|
5106
|
-
|
|
5107
|
-
|
|
5108
|
-
|
|
5109
|
-
//
|
|
5110
|
-
|
|
5111
|
-
|
|
5112
|
-
|
|
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
|
|
5113
6462
|
const running = (wsHandlers === undefined
|
|
5114
6463
|
? Bun.serve({
|
|
5115
|
-
|
|
5116
|
-
reusePort,
|
|
5117
|
-
...bind,
|
|
5118
|
-
...idle,
|
|
6464
|
+
...listening,
|
|
5119
6465
|
...(nativeRoutes === undefined ? {} : { routes: nativeRoutes }),
|
|
5120
|
-
fetch:
|
|
5121
|
-
this.fetch(req, bunPeerPlatform(server, req) as Platform<EnvOf<Ctx>>),
|
|
6466
|
+
fetch: fallback,
|
|
5122
6467
|
})
|
|
5123
6468
|
: Bun.serve<BunWsData>({
|
|
5124
|
-
|
|
5125
|
-
reusePort,
|
|
5126
|
-
...bind,
|
|
5127
|
-
...idle,
|
|
6469
|
+
...listening,
|
|
5128
6470
|
fetch: (req, server) => this.bunFetchWithWebSocket(req, server),
|
|
5129
6471
|
// Bun's `ServerWebSocket<BunWsData>` is runtime-compatible with the handlers' structural
|
|
5130
6472
|
// `BunSocket` view (kept local so `Bun.*` types never leak into the published .d.ts); the
|
|
@@ -5140,7 +6482,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
5140
6482
|
})) as unknown as RunningServer
|
|
5141
6483
|
this.bunServer = running
|
|
5142
6484
|
// Bind `app.publish` to Bun's native broadcast now that the server handle exists. Guarded on the
|
|
5143
|
-
// 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
|
|
5144
6488
|
if (nativePubsub && typeof running.publish === "function") {
|
|
5145
6489
|
const native = running.publish.bind(running)
|
|
5146
6490
|
this.nativePublish = (topic, data) => {
|