@nifrajs/core 1.12.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -1
- package/dist/assurance.d.ts +11 -2
- package/dist/assurance.d.ts.map +1 -1
- package/dist/assurance.js +20 -0
- package/dist/assurance.js.map +1 -1
- package/dist/effect-ledger.d.ts +9 -0
- package/dist/effect-ledger.d.ts.map +1 -0
- package/dist/effect-ledger.js +8 -0
- package/dist/effect-ledger.js.map +1 -0
- package/dist/idempotency-plugin.d.ts +8 -0
- package/dist/idempotency-plugin.d.ts.map +1 -0
- package/dist/idempotency-plugin.js +8 -0
- package/dist/idempotency-plugin.js.map +1 -0
- package/dist/index.d.ts +6 -34
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -30
- package/dist/index.js.map +1 -1
- package/dist/mcp.d.ts +7 -0
- package/dist/mcp.d.ts.map +1 -0
- package/dist/mcp.js +56 -0
- package/dist/mcp.js.map +1 -0
- package/dist/node-direct.d.ts +5 -0
- package/dist/node-direct.d.ts.map +1 -0
- package/dist/node-direct.js +27 -0
- package/dist/node-direct.js.map +1 -0
- package/dist/reflection.d.ts +5 -0
- package/dist/reflection.d.ts.map +1 -1
- package/dist/reflection.js +2 -0
- package/dist/reflection.js.map +1 -1
- package/dist/router/pattern.d.ts +42 -0
- package/dist/router/pattern.d.ts.map +1 -0
- package/dist/router/pattern.js +127 -0
- package/dist/router/pattern.js.map +1 -0
- package/dist/router/router.d.ts +2 -1
- package/dist/router/router.d.ts.map +1 -1
- package/dist/router/router.js +11 -48
- package/dist/router/router.js.map +1 -1
- package/dist/server/client-ip.d.ts +35 -0
- package/dist/server/client-ip.d.ts.map +1 -0
- package/dist/server/client-ip.js +42 -0
- package/dist/server/client-ip.js.map +1 -0
- package/dist/server/context.d.ts +47 -2
- package/dist/server/context.d.ts.map +1 -1
- package/dist/server/contract.d.ts +37 -10
- package/dist/server/contract.d.ts.map +1 -1
- package/dist/server/contract.js +33 -8
- package/dist/server/contract.js.map +1 -1
- package/dist/server/http.d.ts +14 -0
- package/dist/server/http.d.ts.map +1 -0
- package/dist/server/http.js +45 -0
- package/dist/server/http.js.map +1 -0
- package/dist/server/idempotency-lane.d.ts +44 -0
- package/dist/server/idempotency-lane.d.ts.map +1 -0
- package/dist/server/idempotency-lane.js +181 -0
- package/dist/server/idempotency-lane.js.map +1 -0
- package/dist/server/install.d.ts +21 -0
- package/dist/server/install.d.ts.map +1 -0
- package/dist/server/install.js +21 -0
- package/dist/server/install.js.map +1 -0
- package/dist/server/ledger-lane.d.ts +53 -0
- package/dist/server/ledger-lane.d.ts.map +1 -0
- package/dist/server/ledger-lane.js +82 -0
- package/dist/server/ledger-lane.js.map +1 -0
- package/dist/server/mcp-hook.d.ts +28 -0
- package/dist/server/mcp-hook.d.ts.map +1 -0
- package/dist/server/mcp-hook.js +2 -0
- package/dist/server/mcp-hook.js.map +1 -0
- package/dist/server/node-outcome-hook.d.ts +9 -0
- package/dist/server/node-outcome-hook.d.ts.map +1 -0
- package/dist/server/node-outcome-hook.js +2 -0
- package/dist/server/node-outcome-hook.js.map +1 -0
- package/dist/server/node-outcome.d.ts +37 -0
- package/dist/server/node-outcome.d.ts.map +1 -0
- package/dist/server/node-outcome.js +75 -0
- package/dist/server/node-outcome.js.map +1 -0
- package/dist/server/plugin.d.ts +78 -0
- package/dist/server/plugin.d.ts.map +1 -0
- package/dist/server/plugin.js +57 -0
- package/dist/server/plugin.js.map +1 -0
- package/dist/server/query.d.ts +18 -0
- package/dist/server/query.d.ts.map +1 -0
- package/dist/server/query.js +102 -0
- package/dist/server/query.js.map +1 -0
- package/dist/server/registry.d.ts +31 -6
- package/dist/server/registry.d.ts.map +1 -1
- package/dist/server/request-context.d.ts +51 -0
- package/dist/server/request-context.d.ts.map +1 -0
- package/dist/server/request-context.js +184 -0
- package/dist/server/request-context.js.map +1 -0
- package/dist/server/respond.d.ts +17 -0
- package/dist/server/respond.d.ts.map +1 -0
- package/dist/server/respond.js +81 -0
- package/dist/server/respond.js.map +1 -0
- package/dist/server/runtime-core.d.ts +36 -0
- package/dist/server/runtime-core.d.ts.map +1 -0
- package/dist/server/runtime-core.js +51 -0
- package/dist/server/runtime-core.js.map +1 -0
- package/dist/server/server-types.d.ts +203 -0
- package/dist/server/server-types.d.ts.map +1 -0
- package/dist/server/server-types.js +2 -0
- package/dist/server/server-types.js.map +1 -0
- package/dist/server/server.d.ts +135 -358
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +476 -1065
- package/dist/server/server.js.map +1 -1
- package/dist/server/sse-hook.d.ts +10 -0
- package/dist/server/sse-hook.d.ts.map +1 -0
- package/dist/server/sse-hook.js +2 -0
- package/dist/server/sse-hook.js.map +1 -0
- package/dist/server/sse.d.ts +7 -23
- package/dist/server/sse.d.ts.map +1 -1
- package/dist/server/sse.js +18 -0
- package/dist/server/sse.js.map +1 -1
- package/dist/server/websocket.d.ts +17 -1
- package/dist/server/websocket.d.ts.map +1 -1
- package/dist/server/websocket.js.map +1 -1
- package/dist/server/ws-hook.d.ts +8 -11
- package/dist/server/ws-hook.d.ts.map +1 -1
- package/dist/server/ws-hook.js +7 -14
- package/dist/server/ws-hook.js.map +1 -1
- package/dist/server.d.ts +21 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +16 -0
- package/dist/server.js.map +1 -0
- package/dist/ws.d.ts +16 -7
- package/dist/ws.d.ts.map +1 -1
- package/dist/ws.js +25 -10
- package/dist/ws.js.map +1 -1
- package/package.json +82 -6
- package/src/assurance.ts +36 -1
- package/src/effect-ledger.ts +9 -0
- package/src/idempotency-plugin.ts +7 -0
- package/src/index.ts +6 -132
- package/src/mcp.ts +65 -0
- package/src/node-direct.ts +37 -0
- package/src/reflection.ts +7 -0
- package/src/router/pattern.ts +176 -0
- package/src/router/router.ts +11 -69
- package/src/server/client-ip.ts +60 -0
- package/src/server/context.ts +50 -2
- package/src/server/contract.ts +68 -25
- package/src/server/http.ts +58 -0
- package/src/server/idempotency-lane.ts +287 -0
- package/src/server/install.ts +28 -0
- package/src/server/ledger-lane.ts +125 -0
- package/src/server/mcp-hook.ts +41 -0
- package/src/server/node-outcome-hook.ts +9 -0
- package/src/server/node-outcome.ts +112 -0
- package/src/server/plugin.ts +92 -0
- package/src/server/query.ts +119 -0
- package/src/server/registry.ts +36 -6
- package/src/server/request-context.ts +271 -0
- package/src/server/respond.ts +93 -0
- package/src/server/runtime-core.ts +74 -0
- package/src/server/server-types.ts +204 -0
- package/src/server/server.ts +863 -1657
- package/src/server/sse-hook.ts +14 -0
- package/src/server/sse.ts +26 -0
- package/src/server/websocket.ts +20 -0
- package/src/server/ws-hook.ts +8 -19
- package/src/server.ts +92 -0
- package/src/ws.ts +33 -10
- package/dist/invariants.d.ts +0 -70
- package/dist/invariants.d.ts.map +0 -1
- package/dist/invariants.js +0 -487
- package/dist/invariants.js.map +0 -1
- package/src/invariants.ts +0 -604
package/src/server/server.ts
CHANGED
|
@@ -7,19 +7,6 @@ import {
|
|
|
7
7
|
type RequestBudget,
|
|
8
8
|
} from "../budget.ts"
|
|
9
9
|
import { FrameworkError, RouteConfigError } from "../errors.ts"
|
|
10
|
-
import {
|
|
11
|
-
canonicalizeIdempotencyBody,
|
|
12
|
-
computeIdempotencyFingerprint,
|
|
13
|
-
DEFAULT_IDEMPOTENCY_HEADER,
|
|
14
|
-
DEFAULT_IDEMPOTENCY_TTL_MS,
|
|
15
|
-
IdempotencyResponseTooLargeError,
|
|
16
|
-
type IdempotencyStore,
|
|
17
|
-
MemoryIdempotencyStore,
|
|
18
|
-
responseFromStored,
|
|
19
|
-
serializeResponse,
|
|
20
|
-
validIdempotencyKey,
|
|
21
|
-
validIdempotencyNamespace,
|
|
22
|
-
} from "../idempotency.ts"
|
|
23
10
|
import {
|
|
24
11
|
CAPABILITY_GUARD,
|
|
25
12
|
type CapabilityUseEvent,
|
|
@@ -28,31 +15,91 @@ import {
|
|
|
28
15
|
} from "../internal/capability-runtime.ts"
|
|
29
16
|
import {
|
|
30
17
|
type AssuranceDeclaration,
|
|
31
|
-
type AssuranceEvidence,
|
|
32
18
|
assuranceDeclarationsOf,
|
|
33
19
|
assuranceEvidenceFor,
|
|
34
20
|
NIFRA_ASSURANCE_IDS,
|
|
21
|
+
validEvidenceId,
|
|
35
22
|
} from "../internal/route-assurance.ts"
|
|
23
|
+
import type { RequestLedger } from "../ledger.ts"
|
|
36
24
|
import {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
} from "../ledger.ts"
|
|
43
|
-
import { EMPTY_PARAMS, type Method, Router } from "../router/router.ts"
|
|
25
|
+
type CompiledRoutePattern,
|
|
26
|
+
compileRoutePattern,
|
|
27
|
+
decodeRouteParams,
|
|
28
|
+
} from "../router/pattern.ts"
|
|
29
|
+
import { EMPTY_PARAMS, type Method, Router, type RouterMatch } from "../router/router.ts"
|
|
44
30
|
import type {
|
|
45
31
|
InferOutput,
|
|
46
32
|
StandardIssue,
|
|
47
33
|
StandardResult,
|
|
48
34
|
StandardSchemaV1,
|
|
49
35
|
} from "../schema/standard.ts"
|
|
50
|
-
import {
|
|
36
|
+
import { parseContentLength } from "./body.ts"
|
|
37
|
+
import { type ClientIpTrust, resolveClientIp } from "./client-ip.ts"
|
|
51
38
|
import type { Context, Platform, ResponseControls, RouteSchema } from "./context.ts"
|
|
52
|
-
import {
|
|
39
|
+
import { jsonError, pathnameOf, urlPartsOf } from "./http.ts"
|
|
40
|
+
import type { NodeServeOutcome } from "./node-outcome.ts"
|
|
41
|
+
import type { NodeOutcomeRuntime } from "./node-outcome-hook.ts"
|
|
42
|
+
import {
|
|
43
|
+
isUrlEncodedForm,
|
|
44
|
+
type QueryValue,
|
|
45
|
+
queryObjectOf,
|
|
46
|
+
readBoundedForm,
|
|
47
|
+
searchOf,
|
|
48
|
+
} from "./query.ts"
|
|
49
|
+
import { RequestContext, readBoundedJsonSource } from "./request-context.ts"
|
|
50
|
+
import { fusedRespond, fusedRespondNoSet, toResponse } from "./respond.ts"
|
|
51
|
+
import {
|
|
52
|
+
CONTEXT_SEARCH,
|
|
53
|
+
CONTEXT_SET,
|
|
54
|
+
EMPTY_RESPONSE_CONTROLS,
|
|
55
|
+
getNeverAbortSignal,
|
|
56
|
+
getUnboundedRequestBudget,
|
|
57
|
+
type HandlerResult,
|
|
58
|
+
headerOf,
|
|
59
|
+
requestOf,
|
|
60
|
+
} from "./runtime-core.ts"
|
|
61
|
+
|
|
62
|
+
// NodeServeOutcome (the nifra<->node bridge render form) now lives in `./node-outcome.ts`; re-exported
|
|
63
|
+
// so existing importers keep resolving it from the server module.
|
|
64
|
+
export type { NodeServeOutcome }
|
|
65
|
+
|
|
66
|
+
import type { IdempotencyRuntime, ResolvedIdempotency } from "./idempotency-lane.ts"
|
|
67
|
+
import {
|
|
68
|
+
INSTALL_EFFECT_LEDGER,
|
|
69
|
+
INSTALL_IDEMPOTENCY,
|
|
70
|
+
INSTALL_MCP,
|
|
71
|
+
INSTALL_NODE_DIRECT,
|
|
72
|
+
INSTALL_SSE,
|
|
73
|
+
INSTALL_WS,
|
|
74
|
+
} from "./install.ts"
|
|
75
|
+
import type { EffectLedgerRuntime, ResolvedEffectLedger } from "./ledger-lane.ts"
|
|
53
76
|
import { jsonLogger, type Logger } from "./logger.ts"
|
|
54
|
-
import type {
|
|
55
|
-
import
|
|
77
|
+
import type { McpRuntime } from "./mcp-hook.ts"
|
|
78
|
+
import type { IdentityPlugin } from "./plugin.ts"
|
|
79
|
+
import type {
|
|
80
|
+
AddRoute,
|
|
81
|
+
EmptyRegistry,
|
|
82
|
+
OutputOf,
|
|
83
|
+
Registry,
|
|
84
|
+
RouteInfoFor,
|
|
85
|
+
WsRouteInfoFor,
|
|
86
|
+
} from "./registry.ts"
|
|
87
|
+
import type {
|
|
88
|
+
AdmissionController,
|
|
89
|
+
AdmissionDecision,
|
|
90
|
+
McpPromptDescriptor,
|
|
91
|
+
McpResourceDescriptor,
|
|
92
|
+
Middleware,
|
|
93
|
+
PromptArgument,
|
|
94
|
+
PromptMessage,
|
|
95
|
+
ResponseFinalization,
|
|
96
|
+
RouteDescriptor,
|
|
97
|
+
RunningServer,
|
|
98
|
+
ServerOptions,
|
|
99
|
+
ToolAnnotations,
|
|
100
|
+
} from "./server-types.ts"
|
|
101
|
+
import type { SSEInit, TypedSSEStream } from "./sse.ts"
|
|
102
|
+
import type { SseRuntime } from "./sse-hook.ts"
|
|
56
103
|
import type {
|
|
57
104
|
StandardWebSocket,
|
|
58
105
|
TopicRegistry,
|
|
@@ -61,9 +108,9 @@ import type {
|
|
|
61
108
|
WebSocketUpgradeOutcome,
|
|
62
109
|
} from "./websocket.ts"
|
|
63
110
|
import type { BunWsData } from "./ws-bun.ts"
|
|
64
|
-
import {
|
|
111
|
+
import type { WsRuntime } from "./ws-hook.ts"
|
|
65
112
|
|
|
66
|
-
type MaybePromise<T> = T | Promise<T>
|
|
113
|
+
export type MaybePromise<T> = T | Promise<T>
|
|
67
114
|
|
|
68
115
|
/**
|
|
69
116
|
* Internal request view. A real Web `Request` already satisfies this shape, so Web/edge runtimes pass
|
|
@@ -85,17 +132,6 @@ export interface RequestSource {
|
|
|
85
132
|
readonly request?: Request
|
|
86
133
|
}
|
|
87
134
|
|
|
88
|
-
/** The concrete `Request` for a source — itself when a real `Request` was passed (the Web path), or the
|
|
89
|
-
* lazily-built one (the Node adapter). A real `Request` IS a `RequestSource`, so no wrapper is allocated
|
|
90
|
-
* on the Web hot path. */
|
|
91
|
-
function requestOf(source: RequestSource): Request {
|
|
92
|
-
return source.request ?? (source as unknown as Request)
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
function headerOf(source: RequestSource, name: string): string | null {
|
|
96
|
-
return source.header?.(name) ?? source.headers.get(name)
|
|
97
|
-
}
|
|
98
|
-
|
|
99
135
|
/** The empty context extension. `NonNullable<unknown>` is `{}` without tripping noBannedTypes. */
|
|
100
136
|
type EmptyContext = NonNullable<unknown>
|
|
101
137
|
|
|
@@ -106,42 +142,20 @@ type EmptyContext = NonNullable<unknown>
|
|
|
106
142
|
*/
|
|
107
143
|
type EnvOf<Ctx> = Ctx extends { readonly env: infer E } ? E : unknown
|
|
108
144
|
|
|
109
|
-
/** A handler returns a `Response` (used as-is) or any value (serialized to JSON). */
|
|
110
|
-
type HandlerResult = Response | unknown
|
|
111
145
|
type ContextlessHandler = () => MaybePromise<HandlerResult>
|
|
112
146
|
|
|
113
|
-
const RESPONSE_RESULT = Symbol.for("nifra.response.result")
|
|
114
|
-
const CONTEXT_SET = Symbol("nifra.context.set")
|
|
115
|
-
const CONTEXT_SEARCH = Symbol("nifra.context.search")
|
|
116
147
|
const functionToString = Function.prototype.toString
|
|
117
148
|
const CONTEXTLESS_ARROW = /^(?:async\s*)?\(\s*\)\s*(?::[\s\S]*?)?=>/
|
|
118
149
|
|
|
119
|
-
interface ResponseResult {
|
|
120
|
-
readonly [RESPONSE_RESULT]: true
|
|
121
|
-
toResponse(): Response
|
|
122
|
-
toNodeBody?(): {
|
|
123
|
-
readonly status: number
|
|
124
|
-
readonly headers: Readonly<Record<string, string | readonly string[]>> | undefined
|
|
125
|
-
readonly body: string | Uint8Array
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
function isResponseResult(value: unknown): value is ResponseResult {
|
|
130
|
-
return (
|
|
131
|
-
typeof value === "object" &&
|
|
132
|
-
value !== null &&
|
|
133
|
-
(value as { readonly [RESPONSE_RESULT]?: unknown })[RESPONSE_RESULT] === true &&
|
|
134
|
-
typeof (value as { readonly toResponse?: unknown }).toResponse === "function"
|
|
135
|
-
)
|
|
136
|
-
}
|
|
137
|
-
|
|
138
150
|
/** Internal, path-erased runtime context. The typed `Context<Path, S>` is a structural view of this. */
|
|
139
|
-
interface RawContext {
|
|
151
|
+
export interface RawContext {
|
|
140
152
|
readonly req: Request
|
|
141
153
|
readonly request: Request
|
|
142
154
|
readonly json: (body: unknown, init?: ResponseInit | number) => Response
|
|
143
155
|
readonly text: (body: string, init?: ResponseInit | number) => Response
|
|
144
|
-
|
|
156
|
+
// Writable: the lifecycle replaces it with the validated/coerced value when a `params` schema is
|
|
157
|
+
// declared (handlers still see it `readonly` via the public `Context` interface).
|
|
158
|
+
params: Record<string, string>
|
|
145
159
|
query: unknown
|
|
146
160
|
readonly cookies: Readonly<Record<string, string>>
|
|
147
161
|
body: unknown
|
|
@@ -151,17 +165,12 @@ interface RawContext {
|
|
|
151
165
|
readonly signal: AbortSignal
|
|
152
166
|
readonly budget: RequestBudget
|
|
153
167
|
readonly env: unknown
|
|
168
|
+
readonly clientIp: string | undefined
|
|
154
169
|
readonly waitUntil: (promise: Promise<unknown>) => void
|
|
155
170
|
readonly boundedBody: (maxBytes?: number) => Promise<Uint8Array>
|
|
156
171
|
readonly boundedJson: <T = unknown>(maxBytes?: number) => Promise<T>
|
|
157
172
|
}
|
|
158
173
|
|
|
159
|
-
/** Off-edge `waitUntil`: run the background work fire-and-forget, never leaking an unhandled
|
|
160
|
-
* rejection. Edge runtimes pass their own (Workers `ctx.waitUntil`) via the platform arg. */
|
|
161
|
-
const fallbackWaitUntil = (promise: Promise<unknown>): void => {
|
|
162
|
-
void promise.catch(() => {})
|
|
163
|
-
}
|
|
164
|
-
|
|
165
174
|
type InternalHandler = (ctx: RawContext) => MaybePromise<HandlerResult>
|
|
166
175
|
|
|
167
176
|
/** Broad shape so the implementation signature is compatible with both typed overloads. */
|
|
@@ -178,25 +187,33 @@ type RawOnRequest = (req: Request, platform?: Platform) => MaybePromise<OnReques
|
|
|
178
187
|
type RawOnResponse = (response: Response, req: Request) => MaybePromise<Response>
|
|
179
188
|
type RawOnResponseFinalized = (outcome: ResponseFinalization, req: Request) => MaybePromise<void>
|
|
180
189
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
190
|
+
type RouteExecutionRunner = <T, R extends Registry, Ctx>(
|
|
191
|
+
runtime: Server<R, Ctx>,
|
|
192
|
+
entry: RouteEntry,
|
|
193
|
+
source: RequestSource,
|
|
194
|
+
params: Record<string, string>,
|
|
195
|
+
search: string | undefined,
|
|
196
|
+
signal: AbortSignal,
|
|
197
|
+
budget: RequestBudget,
|
|
198
|
+
platform: Platform | undefined,
|
|
199
|
+
finalize: (result: unknown, set: CtxSet) => T,
|
|
200
|
+
wrapResponse: (response: Response) => T,
|
|
201
|
+
) => MaybePromise<T>
|
|
189
202
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
203
|
+
type ContextRouteRunner = <T, R extends Registry, Ctx>(
|
|
204
|
+
runtime: Server<R, Ctx>,
|
|
205
|
+
entry: RouteEntry,
|
|
206
|
+
source: RequestSource,
|
|
207
|
+
ctx: RawContext,
|
|
208
|
+
finalize: (result: unknown, set: CtxSet) => T,
|
|
209
|
+
wrapResponse: (response: Response) => T,
|
|
210
|
+
) => MaybePromise<T>
|
|
211
|
+
|
|
212
|
+
/** Registration-compiled route behavior. Every adapter invokes the same runner; the optional fused
|
|
213
|
+
* renderer is only a response-format specialization of that same selected route semantics. */
|
|
214
|
+
interface RouteExecutionPlan {
|
|
215
|
+
readonly run: RouteExecutionRunner
|
|
216
|
+
readonly fusedWeb: FusedWebRunner | undefined
|
|
200
217
|
}
|
|
201
218
|
|
|
202
219
|
interface RouteEntry {
|
|
@@ -219,33 +236,75 @@ interface RouteEntry {
|
|
|
219
236
|
readonly onError: ReadonlyArray<RawErrorHandler>
|
|
220
237
|
/** Wraps the matched route lifecycle. Empty for the common no-around path. */
|
|
221
238
|
readonly around: ReadonlyArray<RawAround>
|
|
222
|
-
/**
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
readonly
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
239
|
+
/** The single immutable execution decision consumed by portable, Node-direct, and Bun-native paths. */
|
|
240
|
+
readonly execution: RouteExecutionPlan
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** One canonical runtime route fact. The catalog owns matching, reflection, assurance, tool metadata,
|
|
244
|
+
* replay, and native compilation input so batch registration has one commit point. */
|
|
245
|
+
interface CatalogRoute {
|
|
246
|
+
readonly method: Method
|
|
247
|
+
readonly path: string
|
|
248
|
+
readonly pattern: CompiledRoutePattern
|
|
249
|
+
readonly entry: RouteEntry
|
|
250
|
+
readonly descriptor: RouteDescriptor
|
|
251
|
+
readonly assurance: readonly AssuranceDeclaration[]
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Runtime route catalog. Single-route registration mutates directly; multi-route registration replays
|
|
256
|
+
* the existing catalog plus the candidate batch into a staged router, then swaps the complete state only
|
|
257
|
+
* after every route validates. Failed `implement()`/`merge()` batches therefore leave matching and
|
|
258
|
+
* reflection unchanged.
|
|
259
|
+
*/
|
|
260
|
+
class RouteCatalog {
|
|
261
|
+
private matcher = new Router<RouteEntry>()
|
|
262
|
+
private records: CatalogRoute[] = []
|
|
263
|
+
/** Allocation-free reflection view for the common no-assurance case. Derived only at commit time. */
|
|
264
|
+
private descriptors: RouteDescriptor[] = []
|
|
265
|
+
private assurancePresent = false
|
|
266
|
+
|
|
267
|
+
add(route: CatalogRoute): void {
|
|
268
|
+
this.matcher.add(route.method, route.pattern, route.entry)
|
|
269
|
+
this.records.push(route)
|
|
270
|
+
this.descriptors.push(route.descriptor)
|
|
271
|
+
if (route.assurance.length > 0) this.assurancePresent = true
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
addBatch(routes: readonly CatalogRoute[]): void {
|
|
275
|
+
if (routes.length === 0) return
|
|
276
|
+
const nextRecords = this.records.concat(routes)
|
|
277
|
+
const nextDescriptors = this.descriptors.concat(routes.map(({ descriptor }) => descriptor))
|
|
278
|
+
const nextAssurancePresent =
|
|
279
|
+
this.assurancePresent || routes.some((route) => route.assurance.length > 0)
|
|
280
|
+
const staged = new Router<RouteEntry>()
|
|
281
|
+
for (const route of this.records) staged.add(route.method, route.pattern, route.entry)
|
|
282
|
+
for (const route of routes) staged.add(route.method, route.pattern, route.entry)
|
|
283
|
+
this.matcher = staged
|
|
284
|
+
this.records = nextRecords
|
|
285
|
+
this.descriptors = nextDescriptors
|
|
286
|
+
this.assurancePresent = nextAssurancePresent
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
find(method: string, path: string): RouterMatch<RouteEntry> {
|
|
290
|
+
return this.matcher.find(method, path)
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
entries(): readonly CatalogRoute[] {
|
|
294
|
+
return this.records
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
routeDescriptors(): ReadonlyArray<RouteDescriptor> {
|
|
298
|
+
return this.descriptors
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
lastDescriptor(): RouteDescriptor | undefined {
|
|
302
|
+
return this.records[this.records.length - 1]?.descriptor
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
hasAssurance(): boolean {
|
|
306
|
+
return this.assurancePresent
|
|
307
|
+
}
|
|
249
308
|
}
|
|
250
309
|
|
|
251
310
|
/** The fused Web lane: same inputs `routeAndRun` would hand the generic path, a `Response` out. */
|
|
@@ -264,9 +323,20 @@ interface WsEntry {
|
|
|
264
323
|
readonly handler: WebSocketHandler
|
|
265
324
|
}
|
|
266
325
|
|
|
267
|
-
/** Structural view of the Bun `Server`
|
|
326
|
+
/** Structural view of the Bun `Server` the `fetch` 2nd arg exposes (`upgrade` + the socket peer). */
|
|
268
327
|
interface BunUpgradeServer {
|
|
269
328
|
upgrade(request: Request, options?: { data?: BunWsData }): boolean
|
|
329
|
+
requestIP(request: Request): { readonly address: string } | null
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** The socket peer Bun observed, as a `Platform` for the request lifecycle (`undefined` if unknown).
|
|
333
|
+
* Typed structurally on `requestIP` alone so any Bun `Server` (WS or not) satisfies it. */
|
|
334
|
+
function bunPeerPlatform(
|
|
335
|
+
server: { requestIP(request: Request): { readonly address: string } | null },
|
|
336
|
+
req: Request,
|
|
337
|
+
): Platform | undefined {
|
|
338
|
+
const address = server.requestIP(req)?.address
|
|
339
|
+
return address === undefined ? undefined : { clientIp: address }
|
|
270
340
|
}
|
|
271
341
|
|
|
272
342
|
type BunNativeHandler = (request: Request) => MaybePromise<Response>
|
|
@@ -277,12 +347,31 @@ type BunRequestWithParams = Request & { readonly params?: Record<string, string>
|
|
|
277
347
|
const WS_PASS: WebSocketUpgradeOutcome = { kind: "pass" }
|
|
278
348
|
|
|
279
349
|
/** `app.ws()` (and everything downstream of it) needs the runtime `@nifrajs/core/ws` registers. */
|
|
280
|
-
function requireWsRuntime(): WsRuntime {
|
|
281
|
-
const runtime = getWsRuntime()
|
|
350
|
+
function requireWsRuntime(runtime: WsRuntime | undefined): WsRuntime {
|
|
282
351
|
if (runtime === undefined) {
|
|
283
352
|
throw new FrameworkError(
|
|
284
353
|
"WS_RUNTIME_MISSING",
|
|
285
|
-
|
|
354
|
+
"app.ws() needs the WebSocket runtime, which ships as an opt-in plugin so no-WebSocket apps stay lean. Add `.use(websocket())` from `@nifrajs/core/ws` before declaring WS routes.",
|
|
355
|
+
)
|
|
356
|
+
}
|
|
357
|
+
return runtime
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function requireSseRuntime(runtime: SseRuntime | undefined): SseRuntime {
|
|
361
|
+
if (runtime === undefined) {
|
|
362
|
+
throw new FrameworkError(
|
|
363
|
+
"SSE_RUNTIME_MISSING",
|
|
364
|
+
"app.sse() needs the streaming runtime, which ships as a subpath so non-SSE apps stay lean. Add `.use(streaming())` (from `@nifrajs/core/sse`) at your server setup.",
|
|
365
|
+
)
|
|
366
|
+
}
|
|
367
|
+
return runtime
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function requireMcpRuntime(runtime: McpRuntime | undefined): McpRuntime {
|
|
371
|
+
if (runtime === undefined) {
|
|
372
|
+
throw new FrameworkError(
|
|
373
|
+
"MCP_RUNTIME_MISSING",
|
|
374
|
+
"MCP declarations ship as an opt-in runtime so ordinary HTTP apps stay lean. Add `.use(mcp())` (from `@nifrajs/core/mcp`) at your server setup.",
|
|
286
375
|
)
|
|
287
376
|
}
|
|
288
377
|
return runtime
|
|
@@ -305,202 +394,21 @@ export type Handler<
|
|
|
305
394
|
Ctx = EmptyContext,
|
|
306
395
|
> = (ctx: Context<Path, S> & Ctx) => MaybePromise<ResponseOf<S>>
|
|
307
396
|
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
* is untouched.
|
|
324
|
-
*/
|
|
325
|
-
export interface AdmissionController {
|
|
326
|
-
admit(req: Request): AdmissionDecision | Promise<AdmissionDecision>
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
export interface ServerOptions {
|
|
330
|
-
/**
|
|
331
|
-
* Max request body size (bytes), enforced **only when a route declares a body schema** — the cap
|
|
332
|
-
* lives in the schema-validated read path. Default 1_000_000.
|
|
333
|
-
*
|
|
334
|
-
* A route WITHOUT a body schema (raw body, file upload, BYO-validation) that reads `c.req` directly
|
|
335
|
-
* is not auto-bounded — use **`c.boundedBody(maxBytes?)`** / **`c.boundedJson(maxBytes?)`**, which
|
|
336
|
-
* apply this same cap (override per route by passing `maxBytes` — larger for an upload endpoint,
|
|
337
|
-
* smaller to tighten one).
|
|
338
|
-
*/
|
|
339
|
-
readonly maxBodyBytes?: number
|
|
340
|
-
/** Max inbound WebSocket message size (bytes) when `listen()`ing on Bun — frames over this are rejected
|
|
341
|
-
* by the runtime before reaching your handler (so a huge frame can't be JSON-parsed into memory).
|
|
342
|
-
* Default: `maxBodyBytes` (1 MB). */
|
|
343
|
-
readonly wsMaxPayloadBytes?: number
|
|
344
|
-
/** Per-request timeout (ms): a slower request gets a 503 and `ctx.signal` aborts. 0 disables (default). */
|
|
345
|
-
readonly requestTimeoutMs?: number
|
|
346
|
-
/**
|
|
347
|
-
* Admit the public `x-nifra-deadline` header and let it shorten local work. Disabled by default:
|
|
348
|
-
* services that participate in trusted cross-service deadline propagation opt in explicitly,
|
|
349
|
-
* while ordinary/public HTTP routes pay no header-admission tax and ignore hostile client values.
|
|
350
|
-
*/
|
|
351
|
-
readonly acceptInboundDeadlines?: boolean
|
|
352
|
-
/**
|
|
353
|
-
* Maximum time accepted from an inbound `x-nifra-deadline`, in milliseconds. An incoming absolute
|
|
354
|
-
* deadline may shorten this cap (and `requestTimeoutMs`, when configured), never extend it.
|
|
355
|
-
* Default 30_000; applies only when `acceptInboundDeadlines` is enabled and the header is present.
|
|
356
|
-
*/
|
|
357
|
-
readonly maxInboundDeadlineMs?: number
|
|
358
|
-
/** When `listen()`ing, install SIGTERM/SIGINT handlers that gracefully `stop()`. Default false. */
|
|
359
|
-
readonly gracefulSignals?: boolean
|
|
360
|
-
/**
|
|
361
|
-
* Capacity-admission gate (see {@link AdmissionController}). Bounds concurrent in-flight work so a
|
|
362
|
-
* healthy instance sheds load (`429` + `Retry-After`) instead of accepting more than it can finish.
|
|
363
|
-
* Off by default. Enabling it disables the fused native-route fast path (requests route through the
|
|
364
|
-
* shared matched lane where the gate runs); when unset, the request path pays nothing.
|
|
365
|
-
*/
|
|
366
|
-
readonly admission?: AdmissionController
|
|
367
|
-
/**
|
|
368
|
-
* Optional token-only observation hook called by `useCapability(c, id)`. It receives no request,
|
|
369
|
-
* parameters, body, tenant, or values. Routes without capability declarations keep the old hot path.
|
|
370
|
-
*/
|
|
371
|
-
readonly onCapabilityUse?: (event: CapabilityUseEvent) => void
|
|
372
|
-
/**
|
|
373
|
-
* Enable the per-request effect ledger. Each route that declares `schema.capabilities` gets a
|
|
374
|
-
* bounded, token-only ledger; `useCapability(c, id, …)` appends one entry per effect, and the sink
|
|
375
|
-
* receives the sealed ledger when the response settles (only when it recorded entries). Token-only
|
|
376
|
-
* by construction — capability ids, phases, counters, digests; never payloads, values, or the
|
|
377
|
-
* concrete URL (the route *pattern* is recorded). Enabling it moves capability-declaring routes off
|
|
378
|
-
* the fused fast path (they need a per-request context + a settle step); all other routes are
|
|
379
|
-
* unaffected, and when unset the request path pays nothing.
|
|
380
|
-
*/
|
|
381
|
-
readonly effectLedger?: EffectLedgerOptions
|
|
382
|
-
/**
|
|
383
|
-
* Default store backing routes that declare `schema.idempotency` without their own `store`. Defaults
|
|
384
|
-
* to a shared in-process {@link MemoryIdempotencyStore} (created lazily on first idempotent route).
|
|
385
|
-
* Inject a durable store here to make request-scoped dedupe survive restarts app-wide.
|
|
386
|
-
*/
|
|
387
|
-
readonly idempotencyStore?: IdempotencyStore
|
|
388
|
-
/** Structured logger for framework events (redacts secrets). Default: JSON to stderr. */
|
|
389
|
-
readonly logger?: Logger
|
|
390
|
-
/**
|
|
391
|
-
* App-wide fallback fired when a route **without its own `onValidationError`** fails body/query
|
|
392
|
-
* validation. Same contract as the per-route hook (`(issues, ctx, kind) => Response | repaired-value |
|
|
393
|
-
* undefined`): a route's own hook takes precedence, and a route can fall through to the plain `422` by
|
|
394
|
-
* returning `undefined`. Use it for one app-wide error envelope (like tRPC's `errorFormatter` /
|
|
395
|
-
* Fastify's `setErrorHandler`) instead of repeating a formatter per route.
|
|
396
|
-
*/
|
|
397
|
-
readonly onValidationError?: RouteSchema["onValidationError"]
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
/**
|
|
401
|
-
* MCP tool safety hints, surfaced in `tools/list`, that tell an agent how risky a `.tool()` call is — so it
|
|
402
|
-
* can decide whether to auto-invoke or confirm first. All optional; an omitted hint means "unknown". Mirrors
|
|
403
|
-
* the MCP spec's tool `annotations`.
|
|
404
|
-
*/
|
|
405
|
-
export interface ToolAnnotations {
|
|
406
|
-
/** Human-readable display title for the tool, distinct from the machine `name`. */
|
|
407
|
-
readonly title?: string
|
|
408
|
-
/** The tool does not modify its environment (a pure read). */
|
|
409
|
-
readonly readOnlyHint?: boolean
|
|
410
|
-
/** The tool may perform destructive updates — only meaningful when `readOnlyHint` is not `true`. */
|
|
411
|
-
readonly destructiveHint?: boolean
|
|
412
|
-
/** Repeated calls with the same arguments have no additional effect beyond the first. */
|
|
413
|
-
readonly idempotentHint?: boolean
|
|
414
|
-
/** The tool interacts with external entities (an "open world" beyond this server). */
|
|
415
|
-
readonly openWorldHint?: boolean
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
/**
|
|
419
|
-
* A registered route's public descriptor — method, path, and input schemas. The
|
|
420
|
-
* router trie discards the original patterns, so this flat list is what lets tools
|
|
421
|
-
* (e.g. `toOpenAPI`) enumerate routes after registration.
|
|
422
|
-
*/
|
|
423
|
-
export interface RouteDescriptor {
|
|
424
|
-
readonly method: Method
|
|
425
|
-
readonly path: string
|
|
426
|
-
readonly schema: RouteSchema | undefined
|
|
427
|
-
/** Effective enforcement evidence, populated only during reflection/introspection. */
|
|
428
|
-
readonly assurance?: readonly AssuranceEvidence[]
|
|
429
|
-
/** Normalized declared effect tokens, populated only when the route declares any. */
|
|
430
|
-
readonly capabilities?: readonly string[]
|
|
431
|
-
readonly tool?: {
|
|
432
|
-
readonly name: string
|
|
433
|
-
readonly description: string
|
|
434
|
-
readonly annotations?: ToolAnnotations
|
|
435
|
-
}
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
/** A message in an MCP prompt's rendered output (see {@link Server.prompt}). */
|
|
439
|
-
export interface PromptMessage {
|
|
440
|
-
readonly role: "user" | "assistant"
|
|
441
|
-
readonly content: { readonly type: "text"; readonly text: string }
|
|
442
|
-
}
|
|
443
|
-
|
|
444
|
-
/** One declared argument of an MCP prompt, surfaced in `prompts/list`. */
|
|
445
|
-
export interface PromptArgument {
|
|
446
|
-
readonly name: string
|
|
447
|
-
readonly description?: string
|
|
448
|
-
readonly required?: boolean
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
/** An app-declared MCP resource — read-only data an agent can fetch through `nifra mcp`. */
|
|
452
|
-
export interface McpResourceDescriptor {
|
|
453
|
-
readonly uri: string
|
|
454
|
-
readonly name: string
|
|
455
|
-
readonly description?: string
|
|
456
|
-
readonly mimeType?: string
|
|
457
|
-
readonly read: () => MaybePromise<string | { readonly text: string; readonly mimeType?: string }>
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
/** An app-declared MCP prompt — a reusable prompt template an agent can fetch through `nifra mcp`. */
|
|
461
|
-
export interface McpPromptDescriptor {
|
|
462
|
-
readonly name: string
|
|
463
|
-
readonly description: string
|
|
464
|
-
readonly arguments?: readonly PromptArgument[]
|
|
465
|
-
readonly handler: (args: Record<string, string>) => MaybePromise<readonly PromptMessage[]>
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
/**
|
|
469
|
-
* The handle `listen()` returns — the slice of Bun's server nifra holds and exposes.
|
|
470
|
-
* Declared explicitly (rather than `ReturnType<typeof Bun.serve>`) so the public type
|
|
471
|
-
* surface doesn't leak the ambient `Bun` global into consumers' `.d.ts` resolution.
|
|
472
|
-
*/
|
|
473
|
-
export interface RunningServer {
|
|
474
|
-
readonly port: number
|
|
475
|
-
readonly hostname: string
|
|
476
|
-
readonly pendingRequests: number
|
|
477
|
-
stop(closeActiveConnections?: boolean): void
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
/**
|
|
481
|
-
* A bundle of lifecycle hooks applied together via {@link Server.use} — the unit
|
|
482
|
-
* `@nifrajs/middleware` ships (cors, security headers, rate-limit). Every hook is
|
|
483
|
-
* optional and wired to its lifecycle point. Middleware is context-agnostic (sees
|
|
484
|
-
* the base `Context`); `use` does no context-type merging — the full type-merging
|
|
485
|
-
* plugin system is deferred, and `.use` is reserved as its future entry point.
|
|
486
|
-
*/
|
|
487
|
-
export interface Middleware {
|
|
488
|
-
readonly name?: string
|
|
489
|
-
readonly onRequest?: (req: Request, platform?: Platform) => MaybePromise<OnRequestResult>
|
|
490
|
-
readonly around?: <T>(context: Context, next: () => MaybePromise<T>) => MaybePromise<T>
|
|
491
|
-
readonly beforeHandle?: (context: Context) => MaybePromise<unknown>
|
|
492
|
-
readonly afterHandle?: (result: unknown, context: Context) => MaybePromise<unknown>
|
|
493
|
-
readonly onResponse?: (response: Response, req: Request) => MaybePromise<Response>
|
|
494
|
-
readonly onResponseFinalized?: (outcome: ResponseFinalization, req: Request) => MaybePromise<void>
|
|
495
|
-
readonly onError?: (error: unknown, context: Context) => MaybePromise<unknown>
|
|
496
|
-
}
|
|
497
|
-
|
|
498
|
-
/** The terminal response-pipeline outcome observed after every transforming `onResponse` hook. */
|
|
499
|
-
export interface ResponseFinalization {
|
|
500
|
-
/** The final response, or the last response available when a transformation failed. */
|
|
501
|
-
readonly response: Response
|
|
502
|
-
/** A response-hook failure. Terminal observers run fail-open before the error is rethrown. */
|
|
503
|
-
readonly error?: unknown
|
|
397
|
+
// Route/option/descriptor + middleware-bundle types now live in `./server-types.ts`; re-exported so
|
|
398
|
+
// existing importers keep resolving them from the server module.
|
|
399
|
+
export type {
|
|
400
|
+
AdmissionController,
|
|
401
|
+
AdmissionDecision,
|
|
402
|
+
McpPromptDescriptor,
|
|
403
|
+
McpResourceDescriptor,
|
|
404
|
+
Middleware,
|
|
405
|
+
PromptArgument,
|
|
406
|
+
PromptMessage,
|
|
407
|
+
ResponseFinalization,
|
|
408
|
+
RouteDescriptor,
|
|
409
|
+
RunningServer,
|
|
410
|
+
ServerOptions,
|
|
411
|
+
ToolAnnotations,
|
|
504
412
|
}
|
|
505
413
|
|
|
506
414
|
// A plugin operates over arbitrary Server shapes; `any` here is the standard framework escape hatch
|
|
@@ -508,119 +416,19 @@ export interface ResponseFinalization {
|
|
|
508
416
|
// biome-ignore lint/suspicious/noExplicitAny: plugins are generic over any Server's Registry/Context
|
|
509
417
|
export type AnyServer = Server<any, any>
|
|
510
418
|
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
export type
|
|
520
|
-
app: In,
|
|
521
|
-
) => Out) & { readonly pluginName?: string }
|
|
522
|
-
|
|
523
|
-
/**
|
|
524
|
-
* A named type-identity plugin built with {@link defineIdentityPlugin}. It returns the same concrete
|
|
525
|
-
* server type it receives, preserving the caller's typed registry and context across `.use()` while
|
|
526
|
-
* still allowing the plugin to register runtime hooks or handlers.
|
|
527
|
-
*/
|
|
528
|
-
export type IdentityPlugin = (<S extends AnyServer>(app: S) => S) & {
|
|
529
|
-
readonly pluginName?: string
|
|
530
|
-
}
|
|
531
|
-
|
|
532
|
-
/**
|
|
533
|
-
* Name + ergonomics for a plugin that **adds typed context** (`derive`/`decorate`). `app.use(myPlugin)`
|
|
534
|
-
* applies it once; a second `use` of the same name is skipped (idempotent), so plugins can depend on each
|
|
535
|
-
* other without double-registering hooks.
|
|
536
|
-
*
|
|
537
|
-
* ```ts
|
|
538
|
-
* export const requestId = definePlugin("requestId", (app) => app.derive(() => ({ requestId: uuid() })))
|
|
539
|
-
* app.use(requestId) // downstream handlers see c.requestId
|
|
540
|
-
* ```
|
|
541
|
-
*
|
|
542
|
-
* FOOTGUN: only use this for a plugin that adds context. For a plugin that **mounts routes/hooks but adds
|
|
543
|
-
* NO context** (an auth router, an audit logger), use {@link defineRouterPlugin} ({@link defineIdentityPlugin}).
|
|
544
|
-
* `definePlugin((app) => app.get(...))` infers `app: Server<any, any>`, so `.use()` returns `Server<any, any>`
|
|
545
|
-
* and your whole typed client silently collapses to `any` — no type error, no runtime error.
|
|
546
|
-
*/
|
|
547
|
-
export function definePlugin<In extends AnyServer, Out extends AnyServer>(
|
|
548
|
-
name: string,
|
|
549
|
-
apply: (app: In) => Out,
|
|
550
|
-
): NifraPlugin<In, Out> {
|
|
551
|
-
return Object.assign(apply, { pluginName: name }) as NifraPlugin<In, Out>
|
|
552
|
-
}
|
|
553
|
-
|
|
554
|
-
/**
|
|
555
|
-
* Define a type-**identity** plugin: it registers routes/hooks as a side effect but returns the app with
|
|
556
|
-
* its `Registry` + `Context` UNCHANGED. Use this (not {@link definePlugin}) for any plugin that doesn't
|
|
557
|
-
* add context types — e.g. one mounting an auth handler. It threads the caller's *concrete* server type
|
|
558
|
-
* through `use`, so routes declared after `app.use(plugin)` keep their types.
|
|
559
|
-
*
|
|
560
|
-
* Why a dedicated helper: `definePlugin((app) => app)` infers `app: Server<any, any>`, so `use` returns
|
|
561
|
-
* `Server<any, any>` and the whole typed client collapses to `any`. The explicit generic return type here
|
|
562
|
-
* (which a plain `Object.assign` can't preserve) is what keeps `use` returning the precise server type.
|
|
563
|
-
*
|
|
564
|
-
* ```ts
|
|
565
|
-
* export const audit = defineIdentityPlugin("audit", (app) => app.onResponse(logResponse))
|
|
566
|
-
* const api = server().get("/a", h).use(audit).get("/b", h) // /a AND /b stay typed
|
|
567
|
-
* ```
|
|
568
|
-
*/
|
|
569
|
-
export function defineIdentityPlugin(
|
|
570
|
-
name: string,
|
|
571
|
-
apply: <S extends AnyServer>(app: S) => S,
|
|
572
|
-
): IdentityPlugin {
|
|
573
|
-
return Object.assign(apply, { pluginName: name }) as IdentityPlugin
|
|
574
|
-
}
|
|
575
|
-
|
|
576
|
-
/**
|
|
577
|
-
* Alias of {@link defineIdentityPlugin} with a name that says what it's FOR: a plugin that **mounts
|
|
578
|
-
* routes/hooks but adds no context type** (an auth router, an audit logger). Use this — not
|
|
579
|
-
* {@link definePlugin} — for any such plugin, or the typed client silently collapses to `any`. The
|
|
580
|
-
* "identity" in {@link defineIdentityPlugin} refers to the type-identity it preserves; `defineRouterPlugin`
|
|
581
|
-
* is the same thing under a clearer name.
|
|
582
|
-
*
|
|
583
|
-
* Mount routes as a **side effect**, then return the app unchanged (registering via `.get`/`.post` would
|
|
584
|
-
* change the type away from the identity `S`; the mounted routes run but aren't in the caller's typed
|
|
585
|
-
* registry — that's the trade that keeps everything else typed):
|
|
586
|
-
*
|
|
587
|
-
* ```ts
|
|
588
|
-
* export const scim = defineRouterPlugin("scim", (app) => {
|
|
589
|
-
* app.get("/scim/v2/Users", listUsers) // side effect: mounted at runtime
|
|
590
|
-
* return app // return S unchanged → routes added after .use(scim) stay typed
|
|
591
|
-
* })
|
|
592
|
-
* const api = server().get("/a", h).use(scim).get("/b", h) // /a AND /b stay typed
|
|
593
|
-
* ```
|
|
594
|
-
*/
|
|
595
|
-
export const defineRouterPlugin: typeof defineIdentityPlugin = defineIdentityPlugin
|
|
419
|
+
// Plugin definers + their types now live in `./plugin.ts`; re-exported here so `.use()` callers and
|
|
420
|
+
// existing importers keep resolving them from the server module.
|
|
421
|
+
export {
|
|
422
|
+
defineIdentityPlugin,
|
|
423
|
+
definePlugin,
|
|
424
|
+
defineRouterPlugin,
|
|
425
|
+
type NifraPlugin,
|
|
426
|
+
} from "./plugin.ts"
|
|
427
|
+
export type { IdentityPlugin }
|
|
596
428
|
|
|
597
429
|
const DEFAULT_MAX_BODY_BYTES = 1_000_000
|
|
598
430
|
const DEFAULT_DRAIN_MS = 10_000
|
|
599
431
|
const DRAIN_POLL_MS = 10
|
|
600
|
-
const TEXT_DECODER = new TextDecoder()
|
|
601
|
-
/**
|
|
602
|
-
* Shared never-aborting signal for `ctx.signal` when no timeout is armed — created lazily and
|
|
603
|
-
* cached. NOT a module-scope `new AbortController()`: edge runtimes (Cloudflare workerd) forbid
|
|
604
|
-
* constructing one in global scope; the first request builds it inside the handler, then it's
|
|
605
|
-
* reused at zero per-request cost.
|
|
606
|
-
*/
|
|
607
|
-
let neverAbortSignal: AbortSignal | undefined
|
|
608
|
-
let unboundedRequestBudget: RequestBudget | undefined
|
|
609
|
-
const getNeverAbortSignal = (): AbortSignal => {
|
|
610
|
-
neverAbortSignal ??= new AbortController().signal
|
|
611
|
-
return neverAbortSignal
|
|
612
|
-
}
|
|
613
|
-
const getUnboundedRequestBudget = (): RequestBudget => {
|
|
614
|
-
unboundedRequestBudget ??= createUnboundedRequestBudget(getNeverAbortSignal())
|
|
615
|
-
return unboundedRequestBudget
|
|
616
|
-
}
|
|
617
|
-
|
|
618
|
-
function jsonError(status: number, error: string, headers?: Record<string, string>): Response {
|
|
619
|
-
return Response.json(
|
|
620
|
-
{ ok: false, error },
|
|
621
|
-
headers !== undefined ? { status, headers } : { status },
|
|
622
|
-
)
|
|
623
|
-
}
|
|
624
432
|
|
|
625
433
|
/** Same-origin check for a WebSocket handshake (CSWSH default): the `Origin`'s host[:port] must equal the
|
|
626
434
|
* request's own host (from `req.url`, which the runtime builds from the `Host` header). Scheme differs
|
|
@@ -641,178 +449,12 @@ function validationError(issues: ReadonlyArray<StandardIssue>): Response {
|
|
|
641
449
|
return Response.json({ ok: false, error: "validation", issues: serialized }, { status: 422 })
|
|
642
450
|
}
|
|
643
451
|
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
// `req.url` from every supported runtime is an absolute, already-normalized URL, so the pathname is
|
|
651
|
-
// the substring after `scheme://host[:port]` up to `?`/`#`. Query-schema routes also need the search
|
|
652
|
-
// string; parsing both in one scanner avoids the old `pathnameOf()` + `searchOf()` double scan.
|
|
653
|
-
export function urlPartsOf(url: string): UrlParts {
|
|
654
|
-
const schemeEnd = url.indexOf("://")
|
|
655
|
-
const start = schemeEnd === -1 ? url.indexOf("/") : url.indexOf("/", schemeEnd + 3)
|
|
656
|
-
if (start === -1) return { pathname: "/", search: "" }
|
|
657
|
-
|
|
658
|
-
let pathEnd = url.length
|
|
659
|
-
let searchStart = -1
|
|
660
|
-
let searchEnd = url.length
|
|
661
|
-
for (let i = start; i < url.length; i++) {
|
|
662
|
-
const c = url.charCodeAt(i)
|
|
663
|
-
if (c === 63 /* ? */ && searchStart === -1) {
|
|
664
|
-
pathEnd = i
|
|
665
|
-
searchStart = i
|
|
666
|
-
} else if (c === 35 /* # */) {
|
|
667
|
-
if (searchStart === -1) pathEnd = i
|
|
668
|
-
searchEnd = i
|
|
669
|
-
break
|
|
670
|
-
}
|
|
671
|
-
}
|
|
672
|
-
|
|
673
|
-
return {
|
|
674
|
-
pathname: url.slice(start, pathEnd),
|
|
675
|
-
search: searchStart === -1 ? "" : url.slice(searchStart, searchEnd),
|
|
676
|
-
}
|
|
677
|
-
}
|
|
678
|
-
|
|
679
|
-
// Extract the pathname WITHOUT a full WHATWG `new URL(req.url)` parse. Kept as a public-ish helper
|
|
680
|
-
// for tests and callers that only need the path; the request hot path uses `urlPartsOf()` once.
|
|
681
|
-
export function pathnameOf(url: string): string {
|
|
682
|
-
return urlPartsOf(url).pathname
|
|
683
|
-
}
|
|
684
|
-
|
|
685
|
-
// The query string ("?a=1", or "" when absent) — for lazily building `c.query` only when read.
|
|
686
|
-
// The fragment (after the first `#`) bounds the search: a `?` that appears only inside the fragment
|
|
687
|
-
// is NOT a query (matches WHATWG). Fragments never reach the server in `req.url`, so this is purely
|
|
688
|
-
// for provable equivalence with `new URL(req.url).search`.
|
|
689
|
-
export function searchOf(url: string): string {
|
|
690
|
-
return urlPartsOf(url).search
|
|
691
|
-
}
|
|
692
|
-
|
|
693
|
-
/** A query value: a single occurrence is a string; a repeated key promotes to a string[] so an
|
|
694
|
-
* array query schema (`t.array(t.string())`) can validate `?tag=a&tag=b` — last-wins silently
|
|
695
|
-
* dropped values before (audit 2026-06). Single-occurrence keys stay plain strings, so existing
|
|
696
|
-
* `t.string()` schemas are untouched; a repeated key against a string schema now FAILS validation
|
|
697
|
-
* (an explicit 400 beats silently picking one). */
|
|
698
|
-
export type QueryValue = string | string[]
|
|
699
|
-
|
|
700
|
-
/** Accumulate into a NULL-PROTOTYPE record (every call site below creates one): with no inherited
|
|
701
|
-
* `constructor`/`toString`/`__proto__` accessors, a hostile key is just an own data key — the
|
|
702
|
-
* promotion logic can't collide with `Object.prototype` members, and `__proto__` needs no special
|
|
703
|
-
* case (direct assignment on a null-proto object creates an own property). */
|
|
704
|
-
function setQueryValue(out: Record<string, QueryValue>, key: string, value: string): void {
|
|
705
|
-
const existing = out[key]
|
|
706
|
-
if (existing === undefined) {
|
|
707
|
-
out[key] = value
|
|
708
|
-
} else if (typeof existing === "string") {
|
|
709
|
-
out[key] = [existing, value]
|
|
710
|
-
} else {
|
|
711
|
-
existing.push(value)
|
|
712
|
-
}
|
|
713
|
-
}
|
|
714
|
-
|
|
715
|
-
function queryObjectFallback(search: string): Record<string, QueryValue> {
|
|
716
|
-
// Manual iteration instead of Object.fromEntries: repeated keys must promote to arrays
|
|
717
|
-
// (fromEntries is last-wins), and __proto__ needs the same own-property guard.
|
|
718
|
-
const out: Record<string, QueryValue> = Object.create(null) as Record<string, QueryValue>
|
|
719
|
-
for (const [key, value] of new URLSearchParams(search)) {
|
|
720
|
-
setQueryValue(out, key, value)
|
|
721
|
-
}
|
|
722
|
-
return out
|
|
723
|
-
}
|
|
724
|
-
|
|
725
|
-
/**
|
|
726
|
-
* Build the plain object passed to query schemas. Plain ASCII-ish queries avoid
|
|
727
|
-
* `URLSearchParams` + iterator allocations; encoded queries fall back to the Web API so `+`,
|
|
728
|
-
* percent-decoding, malformed escapes, and empty-key behavior stay exact. Repeated keys promote
|
|
729
|
-
* to `string[]` on BOTH paths (see {@link setQueryValue}).
|
|
730
|
-
*/
|
|
731
|
-
// Shared empty result for no-query requests: frozen + null-prototype (same shape contract as the
|
|
732
|
-
// populated path), allocated once instead of per request.
|
|
733
|
-
const EMPTY_QUERY = Object.freeze(Object.create(null)) as Record<string, QueryValue>
|
|
734
|
-
|
|
735
|
-
export function queryObjectOf(search: string): Record<string, QueryValue> {
|
|
736
|
-
const start = search.charCodeAt(0) === 63 /* ? */ ? 1 : 0
|
|
737
|
-
if (start >= search.length) return EMPTY_QUERY
|
|
738
|
-
|
|
739
|
-
for (let i = start; i < search.length; i++) {
|
|
740
|
-
const c = search.charCodeAt(i)
|
|
741
|
-
if (c === 37 /* % */ || c === 43 /* + */) return queryObjectFallback(search)
|
|
742
|
-
}
|
|
743
|
-
|
|
744
|
-
const out: Record<string, QueryValue> = Object.create(null) as Record<string, QueryValue>
|
|
745
|
-
let pos = start
|
|
746
|
-
while (pos <= search.length) {
|
|
747
|
-
const amp = search.indexOf("&", pos)
|
|
748
|
-
const end = amp === -1 ? search.length : amp
|
|
749
|
-
if (end > pos) {
|
|
750
|
-
const eq = search.indexOf("=", pos)
|
|
751
|
-
const split = eq !== -1 && eq < end ? eq : end
|
|
752
|
-
const key = search.slice(pos, split)
|
|
753
|
-
const value = split === end ? "" : search.slice(split + 1, end)
|
|
754
|
-
setQueryValue(out, key, value)
|
|
755
|
-
}
|
|
756
|
-
if (amp === -1) break
|
|
757
|
-
pos = amp + 1
|
|
758
|
-
}
|
|
759
|
-
return out
|
|
760
|
-
}
|
|
761
|
-
|
|
762
|
-
/** `application/x-www-form-urlencoded`, with or without a charset suffix. */
|
|
763
|
-
function isUrlEncodedForm(contentType: string): boolean {
|
|
764
|
-
return (
|
|
765
|
-
contentType === "application/x-www-form-urlencoded" ||
|
|
766
|
-
contentType.startsWith("application/x-www-form-urlencoded;")
|
|
767
|
-
)
|
|
768
|
-
}
|
|
769
|
-
|
|
770
|
-
const FORM_DECODER = new TextDecoder()
|
|
771
|
-
|
|
772
|
-
/**
|
|
773
|
-
* Read an HTML-form body (urlencoded) into the plain object a body schema validates — same byte
|
|
774
|
-
* cap as the JSON path (a lying/absent Content-Length can't force an oversized buffer), same
|
|
775
|
-
* repeated-key → `string[]` promotion as query parsing, same `__proto__` guard.
|
|
776
|
-
*/
|
|
777
|
-
async function readBoundedForm(
|
|
778
|
-
req: RequestSource,
|
|
779
|
-
maxBytes: number,
|
|
780
|
-
): Promise<Record<string, QueryValue> | Response> {
|
|
781
|
-
const read = await readBoundedBytes(req, maxBytes)
|
|
782
|
-
if (!read.ok) {
|
|
783
|
-
return read.status === 413
|
|
784
|
-
? jsonError(413, "payload_too_large")
|
|
785
|
-
: jsonError(400, "invalid_content_length")
|
|
786
|
-
}
|
|
787
|
-
const out: Record<string, QueryValue> = Object.create(null) as Record<string, QueryValue>
|
|
788
|
-
// URLSearchParams owns the format's quirks (`+` as space, percent-decoding, empty keys);
|
|
789
|
-
// it never throws on junk input, so no try/catch is needed here.
|
|
790
|
-
for (const [key, value] of new URLSearchParams(FORM_DECODER.decode(read.bytes))) {
|
|
791
|
-
setQueryValue(out, key, value)
|
|
792
|
-
}
|
|
793
|
-
return out
|
|
794
|
-
}
|
|
795
|
-
|
|
796
|
-
function decodeParams(raw: Record<string, string>): Record<string, string> | null {
|
|
797
|
-
let out: Record<string, string> | undefined
|
|
798
|
-
for (const key in raw) {
|
|
799
|
-
const value = raw[key]!
|
|
800
|
-
// `decodeURIComponent` only changes strings containing `%`; for the common
|
|
801
|
-
// unencoded param (`/users/123`) skip the call and its throw-check entirely —
|
|
802
|
-
// semantically identical (no `%` ⇒ no escapes to decode), ~3× cheaper on that path. If every
|
|
803
|
-
// param is already plain, return the router's per-request object directly and skip a clone.
|
|
804
|
-
if (!value.includes("%")) {
|
|
805
|
-
continue
|
|
806
|
-
}
|
|
807
|
-
try {
|
|
808
|
-
out ??= { ...raw }
|
|
809
|
-
out[key] = decodeURIComponent(value)
|
|
810
|
-
} catch {
|
|
811
|
-
return null
|
|
812
|
-
}
|
|
813
|
-
}
|
|
814
|
-
return out ?? raw
|
|
815
|
-
}
|
|
452
|
+
// `jsonError`, `urlPartsOf`, `pathnameOf` moved to `./http.ts` (a dependency-free leaf shared with the
|
|
453
|
+
// opt-in request lanes); re-exported so existing importers keep resolving from here.
|
|
454
|
+
export { pathnameOf, urlPartsOf } from "./http.ts"
|
|
455
|
+
// Query-string + urlencoded-form parsing now lives in `./query.ts`; re-exported so existing
|
|
456
|
+
// importers keep resolving `searchOf`/`queryObjectOf`/`QueryValue` from here.
|
|
457
|
+
export { type QueryValue, queryObjectOf, searchOf }
|
|
816
458
|
|
|
817
459
|
function hasReplacementParam(params: Record<string, string>): boolean {
|
|
818
460
|
for (const key in params) {
|
|
@@ -823,48 +465,12 @@ function hasReplacementParam(params: Record<string, string>): boolean {
|
|
|
823
465
|
|
|
824
466
|
/** `ctx.set` carrying the lazy backings (`_headers`, `_cookies`) so `toResponse` can skip allocating
|
|
825
467
|
* anything when no handler touched `c.set.*`. Server-internal. */
|
|
826
|
-
type CtxSet = ResponseControls & {
|
|
468
|
+
export type CtxSet = ResponseControls & {
|
|
827
469
|
_headers?: Record<string, string>
|
|
828
470
|
/** Accumulated `Set-Cookie` values — a list, since a `Record` would collapse multiple cookies. */
|
|
829
471
|
_cookies?: string[]
|
|
830
472
|
}
|
|
831
473
|
|
|
832
|
-
class LazyResponseControls implements CtxSet {
|
|
833
|
-
status?: number
|
|
834
|
-
_headers?: Record<string, string>
|
|
835
|
-
_cookies?: string[]
|
|
836
|
-
|
|
837
|
-
get headers(): Record<string, string> {
|
|
838
|
-
this._headers ??= {}
|
|
839
|
-
return this._headers
|
|
840
|
-
}
|
|
841
|
-
|
|
842
|
-
cookie(name: string, value: string, options?: CookieOptions): void {
|
|
843
|
-
// Secure-by-default: HttpOnly + Secure + SameSite=Lax + Path=/, overridable per call.
|
|
844
|
-
const merged: CookieOptions = {
|
|
845
|
-
httpOnly: true,
|
|
846
|
-
secure: true,
|
|
847
|
-
sameSite: "lax",
|
|
848
|
-
path: "/",
|
|
849
|
-
...options,
|
|
850
|
-
}
|
|
851
|
-
this._cookies ??= []
|
|
852
|
-
this._cookies.push(serializeCookie(name, value, merged))
|
|
853
|
-
}
|
|
854
|
-
|
|
855
|
-
deleteCookie(name: string, options?: Pick<CookieOptions, "path" | "domain">): void {
|
|
856
|
-
// Expire immediately; default Path=/, and match the original path/domain or the browser keeps it.
|
|
857
|
-
this._cookies ??= []
|
|
858
|
-
this._cookies.push(
|
|
859
|
-
serializeCookie(name, "", { path: "/", ...options, maxAge: 0, expires: EPOCH }),
|
|
860
|
-
)
|
|
861
|
-
}
|
|
862
|
-
}
|
|
863
|
-
|
|
864
|
-
// Finalization only reads `status`, `_headers`, and `_cookies`. The user-visible mutator methods
|
|
865
|
-
// live on `LazyResponseControls`, created by the `c.set` getter only when user code touches it.
|
|
866
|
-
const EMPTY_RESPONSE_CONTROLS = Object.freeze({}) as CtxSet
|
|
867
|
-
|
|
868
474
|
function responseSet(ctx: RawContext): CtxSet {
|
|
869
475
|
return ctx[CONTEXT_SET]() ?? EMPTY_RESPONSE_CONTROLS
|
|
870
476
|
}
|
|
@@ -878,407 +484,9 @@ function isContextlessNoArgArrow(handler: (context: never) => unknown): boolean
|
|
|
878
484
|
}
|
|
879
485
|
}
|
|
880
486
|
|
|
881
|
-
/** Coerce `c.json`/`c.text`'s second arg — a status number (the common case) or a full `ResponseInit`. */
|
|
882
|
-
function statusInit(init?: ResponseInit | number): ResponseInit | undefined {
|
|
883
|
-
return typeof init === "number" ? { status: init } : init
|
|
884
|
-
}
|
|
885
|
-
|
|
886
|
-
class RequestContext implements RawContext {
|
|
887
|
-
// `declare` keeps TypeScript's class-field emit from first writing `undefined` to every slot; the
|
|
888
|
-
// constructor initializes only the eager request state, while lazy fields remain absent until used.
|
|
889
|
-
declare readonly params: Record<string, string>
|
|
890
|
-
declare body: unknown
|
|
891
|
-
private declare searchValue: string | undefined
|
|
892
|
-
private declare signalValue: AbortSignal | undefined
|
|
893
|
-
private declare budgetValue: RequestBudget | undefined
|
|
894
|
-
private declare platformValue: Platform | undefined
|
|
895
|
-
|
|
896
|
-
private declare setValue: CtxSet | undefined
|
|
897
|
-
private declare queryValue: unknown
|
|
898
|
-
private declare queryReady: boolean
|
|
899
|
-
private declare cookiesValue: Readonly<Record<string, string>> | undefined
|
|
900
|
-
private declare readonly source: RequestSource
|
|
901
|
-
private declare readonly maxBodyBytes: number
|
|
902
|
-
|
|
903
|
-
constructor(source: RequestSource, params: Record<string, string>, maxBodyBytes: number)
|
|
904
|
-
constructor(
|
|
905
|
-
source: RequestSource,
|
|
906
|
-
params: Record<string, string>,
|
|
907
|
-
search: string | undefined,
|
|
908
|
-
signal: AbortSignal,
|
|
909
|
-
budget: RequestBudget,
|
|
910
|
-
platform: Platform | undefined,
|
|
911
|
-
maxBodyBytes: number,
|
|
912
|
-
)
|
|
913
|
-
constructor(
|
|
914
|
-
source: RequestSource,
|
|
915
|
-
params: Record<string, string>,
|
|
916
|
-
searchOrMaxBodyBytes: string | number | undefined,
|
|
917
|
-
signal?: AbortSignal,
|
|
918
|
-
budget?: RequestBudget,
|
|
919
|
-
platform?: Platform,
|
|
920
|
-
maxBodyBytes?: number,
|
|
921
|
-
) {
|
|
922
|
-
this.source = source
|
|
923
|
-
this.params = params
|
|
924
|
-
if (typeof searchOrMaxBodyBytes === "number") {
|
|
925
|
-
this.maxBodyBytes = searchOrMaxBodyBytes
|
|
926
|
-
return
|
|
927
|
-
}
|
|
928
|
-
if (searchOrMaxBodyBytes !== undefined) this.searchValue = searchOrMaxBodyBytes
|
|
929
|
-
this.signalValue = signal
|
|
930
|
-
this.budgetValue = budget
|
|
931
|
-
if (platform !== undefined) this.platformValue = platform
|
|
932
|
-
this.maxBodyBytes = maxBodyBytes as number
|
|
933
|
-
}
|
|
934
|
-
|
|
935
|
-
static native(
|
|
936
|
-
source: RequestSource,
|
|
937
|
-
params: Record<string, string>,
|
|
938
|
-
maxBodyBytes: number,
|
|
939
|
-
): RequestContext {
|
|
940
|
-
return new RequestContext(source, params, maxBodyBytes)
|
|
941
|
-
}
|
|
942
|
-
|
|
943
|
-
[CONTEXT_SET](): CtxSet | undefined {
|
|
944
|
-
return this.setValue
|
|
945
|
-
}
|
|
946
|
-
|
|
947
|
-
get [CONTEXT_SEARCH](): string {
|
|
948
|
-
this.searchValue ??= searchOf(this.source.url)
|
|
949
|
-
return this.searchValue
|
|
950
|
-
}
|
|
951
|
-
|
|
952
|
-
get set(): CtxSet {
|
|
953
|
-
this.setValue ??= new LazyResponseControls()
|
|
954
|
-
return this.setValue
|
|
955
|
-
}
|
|
956
|
-
|
|
957
|
-
get signal(): AbortSignal {
|
|
958
|
-
return this.signalValue ?? getNeverAbortSignal()
|
|
959
|
-
}
|
|
960
|
-
|
|
961
|
-
get budget(): RequestBudget {
|
|
962
|
-
return this.budgetValue ?? getUnboundedRequestBudget()
|
|
963
|
-
}
|
|
964
|
-
|
|
965
|
-
get env(): unknown {
|
|
966
|
-
return this.platformValue?.env
|
|
967
|
-
}
|
|
968
|
-
|
|
969
|
-
get waitUntil(): (promise: Promise<unknown>) => void {
|
|
970
|
-
return this.platformValue?.waitUntil ?? fallbackWaitUntil
|
|
971
|
-
}
|
|
972
|
-
|
|
973
|
-
get req(): Request {
|
|
974
|
-
return requestOf(this.source)
|
|
975
|
-
}
|
|
976
|
-
|
|
977
|
-
get request(): Request {
|
|
978
|
-
return requestOf(this.source)
|
|
979
|
-
}
|
|
980
|
-
|
|
981
|
-
json(body: unknown, init?: ResponseInit | number): Response {
|
|
982
|
-
return Response.json(body, statusInit(init))
|
|
983
|
-
}
|
|
984
|
-
|
|
985
|
-
text(body: string, init?: ResponseInit | number): Response {
|
|
986
|
-
const i = statusInit(init)
|
|
987
|
-
// Default to text/plain; if the caller passes their own headers, they own the content-type.
|
|
988
|
-
if (i?.headers !== undefined) return new Response(body, i)
|
|
989
|
-
return new Response(body, { ...i, headers: { "content-type": "text/plain; charset=utf-8" } })
|
|
990
|
-
}
|
|
991
|
-
|
|
992
|
-
get query(): unknown {
|
|
993
|
-
if (!this.queryReady) {
|
|
994
|
-
this.queryValue = new URLSearchParams(this[CONTEXT_SEARCH])
|
|
995
|
-
this.queryReady = true
|
|
996
|
-
}
|
|
997
|
-
return this.queryValue
|
|
998
|
-
}
|
|
999
|
-
|
|
1000
|
-
set query(v: unknown) {
|
|
1001
|
-
this.queryValue = v
|
|
1002
|
-
this.queryReady = true
|
|
1003
|
-
}
|
|
1004
|
-
|
|
1005
|
-
get cookies(): Readonly<Record<string, string>> {
|
|
1006
|
-
this.cookiesValue ??= parseCookies(headerOf(this.source, "cookie"))
|
|
1007
|
-
return this.cookiesValue
|
|
1008
|
-
}
|
|
1009
|
-
|
|
1010
|
-
boundedBody(maxBytes?: number): Promise<Uint8Array> {
|
|
1011
|
-
return readBoundedBodyOrThrow(this.source, this.maxBodyBytes, maxBytes)
|
|
1012
|
-
}
|
|
1013
|
-
|
|
1014
|
-
boundedJson<T = unknown>(maxBytes?: number): Promise<T> {
|
|
1015
|
-
return readBoundedJsonBodyOrThrow<T>(this.source, this.maxBodyBytes, maxBytes)
|
|
1016
|
-
}
|
|
1017
|
-
}
|
|
1018
|
-
|
|
1019
|
-
/** Backs `c.boundedBody`: bounded byte read that throws a flat 413/400 `Response` (caught by
|
|
1020
|
-
* `runLifecycle` as control flow, like `throw redirect(...)`), so a handler can't ignore the cap.
|
|
1021
|
-
* The byte-cap itself lives in `./body.ts` (shared with the schema path and `verifyWebhook`). */
|
|
1022
|
-
async function readBoundedBodyOrThrow(
|
|
1023
|
-
req: RequestSource,
|
|
1024
|
-
maxBodyBytes: number,
|
|
1025
|
-
maxBytes?: number,
|
|
1026
|
-
): Promise<Uint8Array> {
|
|
1027
|
-
const r = await readBoundedBytes(req, maxBytes ?? maxBodyBytes)
|
|
1028
|
-
if (r.ok) return r.bytes
|
|
1029
|
-
throw r.status === 413
|
|
1030
|
-
? jsonError(413, "payload_too_large")
|
|
1031
|
-
: jsonError(400, "invalid_content_length")
|
|
1032
|
-
}
|
|
1033
|
-
|
|
1034
|
-
/** Backs `c.boundedJson`: `readBoundedBodyOrThrow` + `JSON.parse`, throwing a flat 400 on bad JSON. */
|
|
1035
|
-
async function readBoundedJsonBodyOrThrow<T>(
|
|
1036
|
-
req: RequestSource,
|
|
1037
|
-
maxBodyBytes: number,
|
|
1038
|
-
maxBytes?: number,
|
|
1039
|
-
): Promise<T> {
|
|
1040
|
-
const parsed = await readBoundedJsonSource(req, maxBytes ?? maxBodyBytes)
|
|
1041
|
-
if (parsed instanceof Response) throw parsed
|
|
1042
|
-
return parsed as T
|
|
1043
|
-
}
|
|
1044
|
-
|
|
1045
|
-
/**
|
|
1046
|
-
* Read a JSON body with the same byte cap used by schema validation and `c.boundedJson`.
|
|
1047
|
-
* Non-chunked, framed requests with an in-cap `Content-Length` use the runtime-native `req.json()`;
|
|
1048
|
-
* chunked or length-less requests fall back to the streaming byte-cap guard.
|
|
1049
|
-
*/
|
|
1050
|
-
async function readBoundedJsonSource(
|
|
1051
|
-
req: RequestSource,
|
|
1052
|
-
maxBytes: number,
|
|
1053
|
-
): Promise<unknown | Response> {
|
|
1054
|
-
const declared = headerOf(req, "content-length")
|
|
1055
|
-
if (declared !== null) {
|
|
1056
|
-
// A present Content-Length must be a non-negative integer (HTTP grammar: `1*DIGIT`). A
|
|
1057
|
-
// non-numeric / negative / fractional / exponential value (`Number()` would happily accept
|
|
1058
|
-
// "abc"→NaN, "-5", "1.5", "1e3", "0x10") is malformed → 400, rather than silently falling
|
|
1059
|
-
// through to the streaming guard — which is an UPPER-bound cap only, so a lying SMALLER length
|
|
1060
|
-
// would otherwise be read in full. Real HTTP servers only hand us a valid framed length; this
|
|
1061
|
-
// hardens hand-built Requests (tests, the in-process client) and crafted input.
|
|
1062
|
-
const length = parseContentLength(declared)
|
|
1063
|
-
if (length === undefined) return jsonError(400, "invalid_content_length")
|
|
1064
|
-
if (length > maxBytes) return jsonError(413, "payload_too_large")
|
|
1065
|
-
const chunked = headerOf(req, "transfer-encoding") !== null
|
|
1066
|
-
if (!chunked) {
|
|
1067
|
-
try {
|
|
1068
|
-
return await req.json()
|
|
1069
|
-
} catch {
|
|
1070
|
-
return jsonError(400, "invalid_json")
|
|
1071
|
-
}
|
|
1072
|
-
}
|
|
1073
|
-
}
|
|
1074
|
-
const body = req.body
|
|
1075
|
-
if (body === null) return jsonError(400, "invalid_json")
|
|
1076
|
-
const drained = await drainCapped(body, maxBytes)
|
|
1077
|
-
if (!drained.ok) return jsonError(413, "payload_too_large")
|
|
1078
|
-
try {
|
|
1079
|
-
return JSON.parse(TEXT_DECODER.decode(drained.bytes))
|
|
1080
|
-
} catch {
|
|
1081
|
-
return jsonError(400, "invalid_json")
|
|
1082
|
-
}
|
|
1083
|
-
}
|
|
1084
|
-
|
|
1085
|
-
/** A fixed past instant for cookie deletion (`Expires`). A literal epoch — deterministic, unlike an
|
|
1086
|
-
* argless `new Date()`. */
|
|
1087
|
-
const EPOCH = new Date(0)
|
|
1088
|
-
|
|
1089
|
-
/** Build the response headers init. The common path (no `c.set`) returns `undefined` so `Response`
|
|
1090
|
-
* gets no `headers` at all. Cookies force a `Headers` object — multiple `Set-Cookie`s can't live in a
|
|
1091
|
-
* `Record<string,string>` (the 2nd would overwrite the 1st), so they're `append`ed individually. */
|
|
1092
|
-
function headersInit(set: CtxSet): Record<string, string> | Headers | undefined {
|
|
1093
|
-
const cookies = set._cookies
|
|
1094
|
-
if (cookies === undefined || cookies.length === 0) return set._headers
|
|
1095
|
-
const headers = new Headers(set._headers)
|
|
1096
|
-
for (const cookie of cookies) headers.append("set-cookie", cookie)
|
|
1097
|
-
return headers
|
|
1098
|
-
}
|
|
1099
|
-
|
|
1100
|
-
// Keep the fast JSON respond path byte-identical to `Response.json` without probing it at module
|
|
1101
|
-
// scope: workerd forbids `Response.json()` during startup. A shared `Headers` is safe to reuse
|
|
1102
|
-
// across responses: the Response constructor copies `init.headers` into its own list.
|
|
1103
|
-
const JSON_CT_HEADERS = new Headers({
|
|
1104
|
-
"content-type": "application/json;charset=utf-8",
|
|
1105
|
-
})
|
|
1106
|
-
const JSON_INIT_200: ResponseInit = { status: 200, headers: JSON_CT_HEADERS }
|
|
1107
|
-
|
|
1108
|
-
/** Fused-lane respond when `c.set` is untouched. Bun 1.3's native `Response.json` now beats the
|
|
1109
|
-
* older hand-inlined stringify + Response construction on this lane while preserving the exact
|
|
1110
|
-
* body/content-type contract; keep the generic fallback for non-JSON values. */
|
|
1111
|
-
function fusedRespondNoSet(result: unknown): Response {
|
|
1112
|
-
if (
|
|
1113
|
-
result !== undefined &&
|
|
1114
|
-
!(result instanceof Response) &&
|
|
1115
|
-
typeof result === "object" &&
|
|
1116
|
-
result !== null &&
|
|
1117
|
-
!isResponseResult(result)
|
|
1118
|
-
) {
|
|
1119
|
-
return Response.json(result)
|
|
1120
|
-
}
|
|
1121
|
-
return toResponse(result as HandlerResult, EMPTY_RESPONSE_CONTROLS)
|
|
1122
|
-
}
|
|
1123
|
-
|
|
1124
|
-
/** Fused-lane respond with a context: read `c.set` once; untouched (the common case) → the fast
|
|
1125
|
-
* JSON respond; touched → the generic `toResponse` with those controls (statuses, headers, cookies). */
|
|
1126
|
-
function fusedRespond(result: unknown, ctx: RawContext): Response {
|
|
1127
|
-
const set = ctx[CONTEXT_SET]()
|
|
1128
|
-
if (set === undefined) return fusedRespondNoSet(result)
|
|
1129
|
-
return toResponse(result as HandlerResult, set)
|
|
1130
|
-
}
|
|
1131
|
-
|
|
1132
|
-
function toResponse(result: HandlerResult, set: CtxSet): Response {
|
|
1133
|
-
if (isResponseResult(result)) {
|
|
1134
|
-
return appendCookiesToResponse(result.toResponse(), set)
|
|
1135
|
-
}
|
|
1136
|
-
if (result instanceof Response) {
|
|
1137
|
-
return appendCookiesToResponse(result, set)
|
|
1138
|
-
}
|
|
1139
|
-
const headers = headersInit(set)
|
|
1140
|
-
const status = set.status ?? (result === undefined ? 204 : 200)
|
|
1141
|
-
if (headers === undefined && result !== undefined) {
|
|
1142
|
-
// Fast respond (profiled ≈ −50 ns/req on every plain-JSON return): `JSON.stringify` + a
|
|
1143
|
-
// prebuilt init beats `Response.json`'s internal init handling. Output is byte-identical —
|
|
1144
|
-
// same body bytes, same probed content-type. `undefined` from stringify (a function/symbol
|
|
1145
|
-
// result) delegates to Response.json so its TypeError contract stays the single source.
|
|
1146
|
-
const body = JSON.stringify(result) as string | undefined
|
|
1147
|
-
if (body !== undefined) {
|
|
1148
|
-
return new Response(
|
|
1149
|
-
body,
|
|
1150
|
-
status === 200 ? JSON_INIT_200 : { status, headers: JSON_CT_HEADERS },
|
|
1151
|
-
)
|
|
1152
|
-
}
|
|
1153
|
-
}
|
|
1154
|
-
const init: ResponseInit = headers === undefined ? { status } : { status, headers }
|
|
1155
|
-
return result === undefined ? new Response(null, init) : Response.json(result, init)
|
|
1156
|
-
}
|
|
1157
|
-
|
|
1158
|
-
function appendCookiesToResponse(response: Response, set: CtxSet): Response {
|
|
1159
|
-
// A handler may queue cookies (`c.set.cookie` — e.g. a session cookie) AND return its own Response
|
|
1160
|
-
// (e.g. `redirect("/")` after login). Cookies accumulate additively, so append them to the
|
|
1161
|
-
// returned Response — otherwise the canonical set-session-then-redirect pattern would silently drop
|
|
1162
|
-
// the cookie. (Other `c.set` fields stay the returned Response's own concern.)
|
|
1163
|
-
const cookies = set._cookies
|
|
1164
|
-
if (cookies !== undefined && cookies.length > 0) {
|
|
1165
|
-
for (const cookie of cookies) response.headers.append("set-cookie", cookie)
|
|
1166
|
-
}
|
|
1167
|
-
return response
|
|
1168
|
-
}
|
|
1169
|
-
|
|
1170
|
-
/**
|
|
1171
|
-
* What {@link Server.resolveNode} returns: either a plain-data render the `@nifrajs/node` adapter writes
|
|
1172
|
-
* to the socket directly (`kind: "json"` — status + headers + cookies + a pre-stringified body, **no**
|
|
1173
|
-
* undici `Response` built or drained), a marked buffered response body (`kind: "body"` — e.g.
|
|
1174
|
-
* @nifrajs/web's non-deferred SSR HTML), or a `Response` (`kind: "response"`) for everything else
|
|
1175
|
-
* (redirects, 404/405/errors, unmarked or streaming bodies). Internal to the nifra↔node bridge.
|
|
1176
|
-
*/
|
|
1177
|
-
export type NodeServeOutcome =
|
|
1178
|
-
| { readonly kind: "response"; readonly response: Response }
|
|
1179
|
-
| {
|
|
1180
|
-
readonly kind: "json"
|
|
1181
|
-
readonly status: number
|
|
1182
|
-
/** `c.set.headers` backing, or `undefined` when the handler never set a header. */
|
|
1183
|
-
readonly headers: Readonly<Record<string, string>> | undefined
|
|
1184
|
-
/** Queued `Set-Cookie` lines, or `undefined`; the adapter emits one header line each. */
|
|
1185
|
-
readonly cookies: readonly string[] | undefined
|
|
1186
|
-
/** The JSON body already stringified, or `null` for an empty (204) response. */
|
|
1187
|
-
readonly body: string | null
|
|
1188
|
-
}
|
|
1189
|
-
| {
|
|
1190
|
-
readonly kind: "body"
|
|
1191
|
-
readonly status: number
|
|
1192
|
-
readonly headers: Readonly<Record<string, string | readonly string[]>> | undefined
|
|
1193
|
-
readonly body: string | Uint8Array
|
|
1194
|
-
}
|
|
1195
|
-
|
|
1196
|
-
/**
|
|
1197
|
-
* `finalize` for the node-direct path — mirror of {@link toResponse} that skips the `Response` build:
|
|
1198
|
-
* a plain value becomes pre-stringified JSON primitives (the adapter `JSON.stringify`s once, here, not
|
|
1199
|
-
* via `Response.json` + a body drain); a handler-returned `Response` is wrapped as-is, with queued
|
|
1200
|
-
* cookies appended exactly as `toResponse` does (so the set-cookie-then-`redirect()` pattern still
|
|
1201
|
-
* works on Node).
|
|
1202
|
-
*/
|
|
1203
|
-
function toNodeOutcome(result: HandlerResult, set: CtxSet): NodeServeOutcome {
|
|
1204
|
-
if (isResponseResult(result)) {
|
|
1205
|
-
const body = result.toNodeBody?.()
|
|
1206
|
-
if (body !== undefined) {
|
|
1207
|
-
return {
|
|
1208
|
-
kind: "body",
|
|
1209
|
-
status: body.status,
|
|
1210
|
-
headers: appendCookiesToNodeHeaders(body.headers, set._cookies),
|
|
1211
|
-
body: body.body,
|
|
1212
|
-
}
|
|
1213
|
-
}
|
|
1214
|
-
return nodeOutcomeFromResponse(appendCookiesToResponse(result.toResponse(), set))
|
|
1215
|
-
}
|
|
1216
|
-
if (result instanceof Response) {
|
|
1217
|
-
return nodeOutcomeFromResponse(appendCookiesToResponse(result, set))
|
|
1218
|
-
}
|
|
1219
|
-
const status = set.status ?? (result === undefined ? 204 : 200)
|
|
1220
|
-
return {
|
|
1221
|
-
kind: "json",
|
|
1222
|
-
status,
|
|
1223
|
-
headers: set._headers,
|
|
1224
|
-
cookies: set._cookies,
|
|
1225
|
-
body: result === undefined ? null : JSON.stringify(result),
|
|
1226
|
-
}
|
|
1227
|
-
}
|
|
1228
|
-
|
|
1229
487
|
// Stable module-level finalizers so `fetch`/`resolveNode` allocate no per-request closures.
|
|
1230
488
|
const IDENTITY_RESPONSE = (response: Response): Response => response
|
|
1231
489
|
const RESPONSE_TIMEOUT = (): Response => jsonError(503, "request_timeout")
|
|
1232
|
-
const NODE_RESPONSE_BODY = Symbol.for("nifra.response.body")
|
|
1233
|
-
const NODE_FROM_RESPONSE = (response: Response): NodeServeOutcome =>
|
|
1234
|
-
nodeOutcomeFromResponse(response)
|
|
1235
|
-
const NODE_TIMEOUT = (): NodeServeOutcome => ({
|
|
1236
|
-
kind: "response",
|
|
1237
|
-
response: jsonError(503, "request_timeout"),
|
|
1238
|
-
})
|
|
1239
|
-
|
|
1240
|
-
function nodeOutcomeFromResponse(response: Response): NodeServeOutcome {
|
|
1241
|
-
const body = nodeResponseBody(response)
|
|
1242
|
-
return body === undefined
|
|
1243
|
-
? { kind: "response", response }
|
|
1244
|
-
: { kind: "body", status: response.status, headers: responseHeadersForNode(response), body }
|
|
1245
|
-
}
|
|
1246
|
-
|
|
1247
|
-
function nodeResponseBody(response: Response): string | Uint8Array | undefined {
|
|
1248
|
-
if (response.bodyUsed) return undefined
|
|
1249
|
-
const body = (response as { readonly [NODE_RESPONSE_BODY]?: unknown })[NODE_RESPONSE_BODY]
|
|
1250
|
-
return typeof body === "string" || body instanceof Uint8Array ? body : undefined
|
|
1251
|
-
}
|
|
1252
|
-
|
|
1253
|
-
function responseHeadersForNode(
|
|
1254
|
-
response: Response,
|
|
1255
|
-
): Readonly<Record<string, string | readonly string[]>> | undefined {
|
|
1256
|
-
let headers: Record<string, string | readonly string[]> | undefined
|
|
1257
|
-
response.headers.forEach((value, key) => {
|
|
1258
|
-
headers ??= {}
|
|
1259
|
-
headers[key] = value
|
|
1260
|
-
})
|
|
1261
|
-
const setCookies = response.headers.getSetCookie?.()
|
|
1262
|
-
if (setCookies !== undefined && setCookies.length > 0) {
|
|
1263
|
-
headers ??= {}
|
|
1264
|
-
headers["set-cookie"] = setCookies
|
|
1265
|
-
}
|
|
1266
|
-
return headers
|
|
1267
|
-
}
|
|
1268
|
-
|
|
1269
|
-
function appendCookiesToNodeHeaders(
|
|
1270
|
-
headers: Readonly<Record<string, string | readonly string[]>> | undefined,
|
|
1271
|
-
cookies: readonly string[] | undefined,
|
|
1272
|
-
): Readonly<Record<string, string | readonly string[]>> | undefined {
|
|
1273
|
-
if (cookies === undefined || cookies.length === 0) return headers
|
|
1274
|
-
const out: Record<string, string | readonly string[]> =
|
|
1275
|
-
headers === undefined ? {} : { ...headers }
|
|
1276
|
-
const existing = out["set-cookie"]
|
|
1277
|
-
const setCookies =
|
|
1278
|
-
existing === undefined ? [] : typeof existing === "string" ? [existing] : [...existing]
|
|
1279
|
-
out["set-cookie"] = [...setCookies, ...cookies]
|
|
1280
|
-
return out
|
|
1281
|
-
}
|
|
1282
490
|
|
|
1283
491
|
/**
|
|
1284
492
|
* The inline server. Routes are chainable and fully type-inferred. `derive`/
|
|
@@ -1289,7 +497,7 @@ function appendCookiesToNodeHeaders(
|
|
|
1289
497
|
* .get("/me", (c) => c.user) // c.user + c.db are typed
|
|
1290
498
|
*/
|
|
1291
499
|
export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
1292
|
-
private readonly
|
|
500
|
+
private readonly catalog: RouteCatalog
|
|
1293
501
|
/** WebSocket routes, matched separately at upgrade time (a GET + `Upgrade: websocket`). */
|
|
1294
502
|
private readonly wsRouter: Router<WsEntry>
|
|
1295
503
|
private wsRouteCount: number
|
|
@@ -1297,10 +505,11 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1297
505
|
* Created by the first `app.ws()` via the `@nifrajs/core/ws` runtime — `undefined` until then, so a
|
|
1298
506
|
* no-WebSocket app never constructs (or bundles) it. */
|
|
1299
507
|
private topics: TopicRegistry | undefined
|
|
1300
|
-
private readonly routeList: RouteDescriptor[]
|
|
1301
508
|
private readonly maxBodyBytes: number
|
|
1302
509
|
private readonly wsMaxPayloadBytes: number
|
|
1303
510
|
private readonly requestTimeoutMs: number
|
|
511
|
+
/** Opt-in caller-IP trust declaration; `undefined` = socket peer only, no forwarded header believed. */
|
|
512
|
+
private readonly clientIpTrust: ClientIpTrust | undefined
|
|
1304
513
|
private readonly acceptInboundDeadlines: boolean
|
|
1305
514
|
private readonly maxInboundDeadlineMs: number
|
|
1306
515
|
private readonly deadlineAdmissionOptions: DeadlineAdmissionOptions
|
|
@@ -1308,17 +517,25 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1308
517
|
/** Capacity-admission gate; `undefined` = off (the request path pays nothing). */
|
|
1309
518
|
private readonly capacityGate: AdmissionController | undefined
|
|
1310
519
|
private readonly onCapabilityUse: ((event: CapabilityUseEvent) => void) | undefined
|
|
1311
|
-
/**
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
private
|
|
1317
|
-
|
|
520
|
+
/** The installed effect-ledger runtime (owns the sink + per-route resolution + settle), or
|
|
521
|
+
* `undefined` when the effect-ledger plugin is not installed. */
|
|
522
|
+
private effectLedgerRuntime: EffectLedgerRuntime | undefined
|
|
523
|
+
/** The installed idempotency runtime (owns the app-wide default store + the dedupe lane), or
|
|
524
|
+
* `undefined` when the idempotency plugin is not installed. */
|
|
525
|
+
private idempotencyRuntime: IdempotencyRuntime | undefined
|
|
526
|
+
/** Installed opt-in runtime for `.tool()`/`.resource()`/`.prompt()`; `undefined` until `.use(mcp())`. */
|
|
527
|
+
private mcpRuntime: McpRuntime | undefined
|
|
528
|
+
/** Installed Node-direct renderer for direct `resolveNode()` callers; `undefined` until `.use(nodeDirect())`. */
|
|
529
|
+
private nodeOutcomeRuntime: NodeOutcomeRuntime | undefined
|
|
530
|
+
/** Installed streaming runtime for `.sse()` routes; `undefined` until `.use(streaming())`. */
|
|
531
|
+
private sseRuntime: SseRuntime | undefined
|
|
532
|
+
/** Installed WebSocket runtime for `.ws()` routes; `undefined` until `.use(websocket())`. */
|
|
533
|
+
private wsRuntime: WsRuntime | undefined
|
|
1318
534
|
private readonly logger: Logger
|
|
1319
535
|
/** App-wide validation-error fallback; a route's own `schema.onValidationError` takes precedence. */
|
|
1320
536
|
private readonly defaultOnValidationError?: RouteSchema["onValidationError"]
|
|
1321
537
|
private bunServer: RunningServer | undefined
|
|
538
|
+
private sealed: boolean
|
|
1322
539
|
private readonly derives: RawDerive[]
|
|
1323
540
|
private readonly decorations: Record<string, unknown>
|
|
1324
541
|
private readonly beforeHandleHooks: RawBeforeHandle[]
|
|
@@ -1338,29 +555,15 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1338
555
|
/** App-declared MCP resources / prompts (via {@link resource} / {@link prompt}), read by `nifra mcp`. */
|
|
1339
556
|
private readonly mcpResourceList: McpResourceDescriptor[]
|
|
1340
557
|
private readonly mcpPromptList: McpPromptDescriptor[]
|
|
1341
|
-
/**
|
|
1342
|
-
* Every registered route's raw router entry + descriptor, in order — the replay source for
|
|
1343
|
-
* {@link merge}. The entry already carries the chains captured at registration (derives,
|
|
1344
|
-
* decorations, before/after/around/onError), so a merged route behaves exactly as it did on
|
|
1345
|
-
* the server that defined it.
|
|
1346
|
-
*/
|
|
1347
|
-
private readonly rawEntries: Array<{
|
|
1348
|
-
readonly method: Method
|
|
1349
|
-
readonly path: string
|
|
1350
|
-
readonly entry: RouteEntry
|
|
1351
|
-
readonly descriptor: RouteDescriptor
|
|
1352
|
-
readonly assurance: readonly AssuranceDeclaration[]
|
|
1353
|
-
}>
|
|
1354
|
-
|
|
1355
558
|
constructor(options: ServerOptions = {}) {
|
|
1356
|
-
this.
|
|
559
|
+
this.catalog = new RouteCatalog()
|
|
1357
560
|
this.wsRouter = new Router<WsEntry>()
|
|
1358
561
|
this.wsRouteCount = 0
|
|
1359
562
|
this.topics = undefined
|
|
1360
|
-
this.routeList = []
|
|
1361
563
|
this.maxBodyBytes = options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES
|
|
1362
564
|
this.wsMaxPayloadBytes = options.wsMaxPayloadBytes ?? this.maxBodyBytes
|
|
1363
565
|
this.requestTimeoutMs = options.requestTimeoutMs ?? 0
|
|
566
|
+
this.clientIpTrust = options.clientIp
|
|
1364
567
|
this.acceptInboundDeadlines = options.acceptInboundDeadlines ?? false
|
|
1365
568
|
this.maxInboundDeadlineMs = options.maxInboundDeadlineMs ?? 30_000
|
|
1366
569
|
this.deadlineAdmissionOptions = Object.freeze({
|
|
@@ -1376,28 +579,16 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1376
579
|
this.gracefulSignals = options.gracefulSignals ?? false
|
|
1377
580
|
this.capacityGate = options.admission
|
|
1378
581
|
this.onCapabilityUse = options.onCapabilityUse
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
if (!Number.isSafeInteger(maxEntries) || maxEntries < 1) {
|
|
1386
|
-
throw new RangeError("effectLedger.maxEntries must be a positive safe integer")
|
|
1387
|
-
}
|
|
1388
|
-
this.effectLedger = Object.freeze({
|
|
1389
|
-
sink: ledger.sink,
|
|
1390
|
-
maxEntries,
|
|
1391
|
-
chain: ledger.chain ?? false,
|
|
1392
|
-
})
|
|
1393
|
-
} else {
|
|
1394
|
-
this.effectLedger = undefined
|
|
1395
|
-
}
|
|
1396
|
-
this.configuredIdempotencyStore = options.idempotencyStore
|
|
1397
|
-
this.lazyIdempotencyStore = undefined
|
|
582
|
+
// The effect-ledger runtime is installed by `.use(effectLedger())`; a bare app never imports it, so
|
|
583
|
+
// the ledger machinery tree-shakes out. Capability-declaring routes simply carry no ledger without it.
|
|
584
|
+
this.effectLedgerRuntime = undefined
|
|
585
|
+
// The idempotency runtime is installed by `.use(idempotency())`; a bare app never imports it, so the
|
|
586
|
+
// dedupe machinery tree-shakes out. A route that declares idempotency without it is a build error.
|
|
587
|
+
this.idempotencyRuntime = undefined
|
|
1398
588
|
this.logger = options.logger ?? jsonLogger()
|
|
1399
589
|
this.defaultOnValidationError = options.onValidationError
|
|
1400
590
|
this.bunServer = undefined
|
|
591
|
+
this.sealed = false
|
|
1401
592
|
this.derives = []
|
|
1402
593
|
this.decorations = {}
|
|
1403
594
|
this.beforeHandleHooks = []
|
|
@@ -1413,17 +604,27 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1413
604
|
this.globalAssurance = []
|
|
1414
605
|
this.mcpResourceList = []
|
|
1415
606
|
this.mcpPromptList = []
|
|
1416
|
-
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
private assertConfigurable(operation: string): void {
|
|
610
|
+
if (this.sealed) {
|
|
611
|
+
throw new FrameworkError(
|
|
612
|
+
"SERVER_SEALED",
|
|
613
|
+
`server configuration is sealed after listen(); call ${operation} before listen()`,
|
|
614
|
+
)
|
|
615
|
+
}
|
|
1417
616
|
}
|
|
1418
617
|
|
|
1419
618
|
/** Add a per-request, computed context extension for subsequent routes. */
|
|
1420
619
|
derive<D extends object>(fn: (context: Context & Ctx) => MaybePromise<D>): Server<R, Ctx & D> {
|
|
620
|
+
this.assertConfigurable("derive()")
|
|
1421
621
|
this.derives.push(fn as unknown as RawDerive)
|
|
1422
622
|
return this as unknown as Server<R, Ctx & D>
|
|
1423
623
|
}
|
|
1424
624
|
|
|
1425
625
|
/** Add a static context value for subsequent routes. */
|
|
1426
626
|
decorate<const K extends string, V>(key: K, value: V): Server<R, Ctx & Record<K, V>> {
|
|
627
|
+
this.assertConfigurable("decorate()")
|
|
1427
628
|
this.decorations[key] = value
|
|
1428
629
|
return this as unknown as Server<R, Ctx & Record<K, V>>
|
|
1429
630
|
}
|
|
@@ -1435,12 +636,14 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1435
636
|
onRequest(
|
|
1436
637
|
fn: (req: Request, platform?: Platform<EnvOf<Ctx>>) => MaybePromise<OnRequestResult>,
|
|
1437
638
|
): this {
|
|
639
|
+
this.assertConfigurable("onRequest()")
|
|
1438
640
|
this.onRequestHooks.push(fn as RawOnRequest)
|
|
1439
641
|
return this
|
|
1440
642
|
}
|
|
1441
643
|
|
|
1442
644
|
/** Run after validation, before the handler; a non-`undefined` return short-circuits. Order-scoped. */
|
|
1443
645
|
beforeHandle(fn: (context: Context & Ctx) => MaybePromise<unknown>): this {
|
|
646
|
+
this.assertConfigurable("beforeHandle()")
|
|
1444
647
|
this.beforeHandleHooks.push(fn as unknown as RawBeforeHandle)
|
|
1445
648
|
return this
|
|
1446
649
|
}
|
|
@@ -1451,24 +654,28 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1451
654
|
* a Web `Response`. The first registered wrapper is outermost.
|
|
1452
655
|
*/
|
|
1453
656
|
around(fn: <T>(context: Context & Ctx, next: () => MaybePromise<T>) => MaybePromise<T>): this {
|
|
657
|
+
this.assertConfigurable("around()")
|
|
1454
658
|
this.aroundHooks.push(fn as unknown as RawAround)
|
|
1455
659
|
return this
|
|
1456
660
|
}
|
|
1457
661
|
|
|
1458
662
|
/** Transform the handler's result before it is serialized. Order-scoped. */
|
|
1459
663
|
afterHandle(fn: (result: unknown, context: Context & Ctx) => MaybePromise<unknown>): this {
|
|
664
|
+
this.assertConfigurable("afterHandle()")
|
|
1460
665
|
this.afterHandleHooks.push(fn as unknown as RawAfterHandle)
|
|
1461
666
|
return this
|
|
1462
667
|
}
|
|
1463
668
|
|
|
1464
669
|
/** Handle a thrown error; a non-`undefined` return becomes the response (else the default 500). Order-scoped. */
|
|
1465
670
|
onError(fn: (error: unknown, context: Context & Ctx) => MaybePromise<unknown>): this {
|
|
671
|
+
this.assertConfigurable("onError()")
|
|
1466
672
|
this.onErrorHooks.push(fn as unknown as RawErrorHandler)
|
|
1467
673
|
return this
|
|
1468
674
|
}
|
|
1469
675
|
|
|
1470
676
|
/** Transform every outgoing response — success, error, 404, 405, short-circuit. Global. */
|
|
1471
677
|
onResponse(fn: (response: Response, req: Request) => MaybePromise<Response>): this {
|
|
678
|
+
this.assertConfigurable("onResponse()")
|
|
1472
679
|
this.onResponseHooks.push(fn)
|
|
1473
680
|
return this
|
|
1474
681
|
}
|
|
@@ -1477,6 +684,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1477
684
|
onResponseFinalized(
|
|
1478
685
|
fn: (outcome: ResponseFinalization, req: Request) => MaybePromise<void>,
|
|
1479
686
|
): this {
|
|
687
|
+
this.assertConfigurable("onResponseFinalized()")
|
|
1480
688
|
this.onResponseFinalizedHooks.push(fn)
|
|
1481
689
|
return this
|
|
1482
690
|
}
|
|
@@ -1504,6 +712,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1504
712
|
*/
|
|
1505
713
|
use(mw: Middleware): this
|
|
1506
714
|
use(arg: Middleware | ((app: this) => AnyServer)): AnyServer {
|
|
715
|
+
this.assertConfigurable("use()")
|
|
1507
716
|
if (typeof arg === "function") {
|
|
1508
717
|
const name = (arg as { pluginName?: string }).pluginName
|
|
1509
718
|
if (name !== undefined) {
|
|
@@ -1570,7 +779,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1570
779
|
* JSON-serialized into the SSE `data:` field. The typed client sees the marker and grows a
|
|
1571
780
|
* `.subscribe(onEvent)` for the route with the same payload type — end-to-end typed streaming.
|
|
1572
781
|
*
|
|
1573
|
-
*
|
|
782
|
+
* import { streaming } from "@nifrajs/core/sse" // .use(streaming()) enables .sse()
|
|
783
|
+
* const app = server().use(streaming()).sse("/feed", { sse: t.object({ id: t.integer(), title: t.string() }) },
|
|
1574
784
|
* async (c, stream) => {
|
|
1575
785
|
* stream.send({ id: 1, title: "hello" }) // typed
|
|
1576
786
|
* await waitForDisconnect(stream.signal)
|
|
@@ -1593,7 +803,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1593
803
|
init?: SSEInit,
|
|
1594
804
|
): Server<AddRoute<R, "GET", Path, RouteInfoFor<Path, S, Response>>, Ctx> {
|
|
1595
805
|
const handler = (context: Context<Path, S> & Ctx): Response =>
|
|
1596
|
-
|
|
806
|
+
requireSseRuntime(this.sseRuntime).response(context, (stream) => run(context, stream), init)
|
|
1597
807
|
return this.route("GET", path, schema, handler as unknown as ErasedHandler) as Server<
|
|
1598
808
|
AddRoute<R, "GET", Path, RouteInfoFor<Path, S, Response>>,
|
|
1599
809
|
Ctx
|
|
@@ -1668,6 +878,19 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1668
878
|
return this.route("DELETE", path, schemaOrHandler, handler)
|
|
1669
879
|
}
|
|
1670
880
|
|
|
881
|
+
/**
|
|
882
|
+
* Declare an **MCP tool** an agent can call (via `nifra mcp`, or a mounted MCP endpoint): a typed
|
|
883
|
+
* `POST /_nifra/tool/<name>` route whose `input`/`output` schemas contract the call and surface in
|
|
884
|
+
* `tools/list`. Requires `.use(mcp())` - without it, `.tool()` is a registration error, so an
|
|
885
|
+
* ordinary HTTP app never bundles the MCP wiring. Siblings: {@link resource}, {@link prompt}.
|
|
886
|
+
*
|
|
887
|
+
* import { mcp } from "@nifrajs/core/mcp"
|
|
888
|
+
* const app = server().use(mcp()).tool(
|
|
889
|
+
* "search",
|
|
890
|
+
* { description: "Search posts", input: t.object({ q: t.string() }) },
|
|
891
|
+
* ({ q }) => findPosts(q),
|
|
892
|
+
* )
|
|
893
|
+
*/
|
|
1671
894
|
tool<
|
|
1672
895
|
Name extends string,
|
|
1673
896
|
S extends {
|
|
@@ -1709,24 +932,17 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1709
932
|
},
|
|
1710
933
|
handler: (input: unknown, ctx: Context & Ctx) => unknown,
|
|
1711
934
|
): Server<Registry, Ctx> {
|
|
1712
|
-
const
|
|
1713
|
-
|
|
1714
|
-
config
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
// the overload above, so bridge with a single cast rather than `any`.
|
|
1719
|
-
const run = (c: Context & Ctx): unknown => handler(c.body, c)
|
|
1720
|
-
this.register("POST", path, routeSchema, run as (context: never) => unknown)
|
|
935
|
+
const plan = requireMcpRuntime(this.mcpRuntime).tool(
|
|
936
|
+
name,
|
|
937
|
+
config,
|
|
938
|
+
handler as (input: unknown, context: Context) => unknown,
|
|
939
|
+
)
|
|
940
|
+
this.register("POST", plan.path, plan.schema, plan.run as (context: never) => unknown)
|
|
1721
941
|
// Tag the just-registered descriptor as an MCP tool. `tool` is readonly on RouteDescriptor (an
|
|
1722
942
|
// introspection field), so write it through a narrow mutable view — not `any`.
|
|
1723
|
-
const lastRoute = this.
|
|
943
|
+
const lastRoute = this.catalog.lastDescriptor()
|
|
1724
944
|
if (lastRoute) {
|
|
1725
|
-
;(lastRoute as { tool?: RouteDescriptor["tool"] }).tool =
|
|
1726
|
-
name,
|
|
1727
|
-
description: config.description,
|
|
1728
|
-
...(config.annotations !== undefined ? { annotations: config.annotations } : {}),
|
|
1729
|
-
}
|
|
945
|
+
;(lastRoute as { tool?: RouteDescriptor["tool"] }).tool = plan.descriptor
|
|
1730
946
|
}
|
|
1731
947
|
return this as unknown as Server<Registry, Ctx>
|
|
1732
948
|
}
|
|
@@ -1742,13 +958,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1742
958
|
config: { readonly name: string; readonly description?: string; readonly mimeType?: string },
|
|
1743
959
|
read: McpResourceDescriptor["read"],
|
|
1744
960
|
): Server<R, Ctx> {
|
|
1745
|
-
this.
|
|
1746
|
-
|
|
1747
|
-
name: config.name,
|
|
1748
|
-
...(config.description !== undefined ? { description: config.description } : {}),
|
|
1749
|
-
...(config.mimeType !== undefined ? { mimeType: config.mimeType } : {}),
|
|
1750
|
-
read,
|
|
1751
|
-
})
|
|
961
|
+
this.assertConfigurable("resource()")
|
|
962
|
+
this.mcpResourceList.push(requireMcpRuntime(this.mcpRuntime).resource(uri, config, read))
|
|
1752
963
|
return this
|
|
1753
964
|
}
|
|
1754
965
|
|
|
@@ -1761,12 +972,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1761
972
|
config: { readonly description: string; readonly arguments?: readonly PromptArgument[] },
|
|
1762
973
|
handler: McpPromptDescriptor["handler"],
|
|
1763
974
|
): Server<R, Ctx> {
|
|
1764
|
-
this.
|
|
1765
|
-
|
|
1766
|
-
description: config.description,
|
|
1767
|
-
...(config.arguments !== undefined ? { arguments: config.arguments } : {}),
|
|
1768
|
-
handler,
|
|
1769
|
-
})
|
|
975
|
+
this.assertConfigurable("prompt()")
|
|
976
|
+
this.mcpPromptList.push(requireMcpRuntime(this.mcpRuntime).prompt(name, config, handler))
|
|
1770
977
|
return this
|
|
1771
978
|
}
|
|
1772
979
|
|
|
@@ -1787,15 +994,29 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1787
994
|
* adapter (`listen()`, `@nifrajs/node`, `@nifrajs/deno`, `toFetchHandler`) — not by bare `app.fetch`, which
|
|
1788
995
|
* has no socket (a WS path through `app.fetch` is a normal HTTP response).
|
|
1789
996
|
*
|
|
997
|
+
* The route also enters the type-level registry (under the pseudo-method `"WS"`), so the typed
|
|
998
|
+
* client grows a `.ws()` handle for it: `messageSchema` types what the client may `send`,
|
|
999
|
+
* `sendSchema` types the frames it receives. Passing explicit type arguments (`ws<MyData>(…)`)
|
|
1000
|
+
* defeats path-literal inference and skips the registry entry - the route still serves, it is just
|
|
1001
|
+
* invisible to `client<App>`; prefer typing `data` via `upgrade()`'s return.
|
|
1002
|
+
*
|
|
1790
1003
|
* app.ws("/chat", { open: (ws) => ws.send("hi"), message: (ws, data) => ws.send(data) })
|
|
1791
1004
|
*/
|
|
1792
|
-
ws<
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1005
|
+
ws<
|
|
1006
|
+
Data = unknown,
|
|
1007
|
+
Schema extends StandardSchemaV1 | undefined = undefined,
|
|
1008
|
+
Send extends StandardSchemaV1 | undefined = undefined,
|
|
1009
|
+
Path extends string = string,
|
|
1010
|
+
>(
|
|
1011
|
+
path: Path,
|
|
1012
|
+
handler: WebSocketHandler<Data, EnvOf<Ctx>, Schema, Send>,
|
|
1013
|
+
): string extends Path
|
|
1014
|
+
? Server<R, Ctx>
|
|
1015
|
+
: Server<AddRoute<R, "WS", Path, WsRouteInfoFor<Path, Schema, Send>>, Ctx> {
|
|
1016
|
+
this.assertConfigurable("ws()")
|
|
1796
1017
|
// Boot-time guard: the WS runtime is a subpath (`@nifrajs/core/ws`) so no-WebSocket apps don't
|
|
1797
1018
|
// bundle it. Registration is the loud, early failure point — never the first connection.
|
|
1798
|
-
const runtime = requireWsRuntime()
|
|
1019
|
+
const runtime = requireWsRuntime(this.wsRuntime)
|
|
1799
1020
|
this.topics ??= runtime.createTopics()
|
|
1800
1021
|
// A `messageSchema` wraps `message` with validation once, here — every adapter then dispatches
|
|
1801
1022
|
// already-validated, typed messages (Bun/Deno/Node/Workers) with no per-adapter code.
|
|
@@ -1803,7 +1024,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1803
1024
|
handler: runtime.wrapHandler(handler as WebSocketHandler),
|
|
1804
1025
|
})
|
|
1805
1026
|
this.wsRouteCount += 1
|
|
1806
|
-
return this
|
|
1027
|
+
return this as never
|
|
1807
1028
|
}
|
|
1808
1029
|
|
|
1809
1030
|
/**
|
|
@@ -1848,6 +1069,34 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1848
1069
|
schema: RouteSchema | undefined,
|
|
1849
1070
|
handler: (context: never) => unknown,
|
|
1850
1071
|
): void {
|
|
1072
|
+
this.assertConfigurable("route registration")
|
|
1073
|
+
this.catalog.add(this.prepareRoute(method, path, schema, handler))
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
/** Register a contract/group route batch atomically. Every route captures the same current chain it
|
|
1077
|
+
* would capture through {@link register}; no route becomes visible unless the full batch validates. */
|
|
1078
|
+
registerBatch(
|
|
1079
|
+
routes: readonly {
|
|
1080
|
+
readonly method: Method
|
|
1081
|
+
readonly path: string
|
|
1082
|
+
readonly schema: RouteSchema | undefined
|
|
1083
|
+
readonly handler: (context: never) => unknown
|
|
1084
|
+
}[],
|
|
1085
|
+
): void {
|
|
1086
|
+
this.assertConfigurable("route registration")
|
|
1087
|
+
const staged = routes.map(({ method, path, schema, handler }) =>
|
|
1088
|
+
this.prepareRoute(method, path, schema, handler),
|
|
1089
|
+
)
|
|
1090
|
+
this.catalog.addBatch(staged)
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
private prepareRoute(
|
|
1094
|
+
method: Method,
|
|
1095
|
+
path: string,
|
|
1096
|
+
schema: RouteSchema | undefined,
|
|
1097
|
+
handler: (context: never) => unknown,
|
|
1098
|
+
): CatalogRoute {
|
|
1099
|
+
const pattern = compileRoutePattern(path)
|
|
1851
1100
|
const capabilities = normalizeRouteCapabilities(schema?.capabilities)
|
|
1852
1101
|
const handlerAssurance = assuranceDeclarationsOf(handler as unknown as object)
|
|
1853
1102
|
const invalidHandlerScope = handlerAssurance.find(
|
|
@@ -1876,16 +1125,26 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1876
1125
|
const hasDecorations = Reflect.ownKeys(routeDecorations).length > 0
|
|
1877
1126
|
// An idempotency route runs a dedupe lane that must buffer the body and capture the response, so it
|
|
1878
1127
|
// never takes the fused/native fast path — force it onto the portable matched lane (which routes
|
|
1879
|
-
// through `fetchMatched`, where the dedupe wrapper lives).
|
|
1880
|
-
|
|
1881
|
-
//
|
|
1128
|
+
// through `fetchMatched`, where the dedupe wrapper lives). Fail closed: a route may not declare
|
|
1129
|
+
// idempotency unless the idempotency runtime is installed, so the safety gate can never be silently
|
|
1130
|
+
// dropped by a missing plugin.
|
|
1131
|
+
if (schema?.idempotency !== undefined && this.idempotencyRuntime === undefined) {
|
|
1132
|
+
throw new RouteConfigError(
|
|
1133
|
+
"INVALID_IDEMPOTENCY",
|
|
1134
|
+
"route declares idempotency but the idempotency plugin is not installed; add .use(idempotency())",
|
|
1135
|
+
)
|
|
1136
|
+
}
|
|
1137
|
+
const idempotent = this.idempotencyRuntime?.resolve(schema, authenticated, this.maxBodyBytes)
|
|
1138
|
+
// A ledgered route (capabilities declared + `.use(effectLedger())`) needs a per-request
|
|
1882
1139
|
// context to carry the ledger and a settle step to seal + sink it, so it too leaves the
|
|
1883
1140
|
// fused/contextless fast path. Resolved per route, at registration — like the capability guard.
|
|
1884
|
-
const ledgered: ResolvedEffectLedger | undefined =
|
|
1885
|
-
capabilities
|
|
1886
|
-
|
|
1887
|
-
|
|
1141
|
+
const ledgered: ResolvedEffectLedger | undefined = this.effectLedgerRuntime?.resolve(
|
|
1142
|
+
capabilities,
|
|
1143
|
+
method,
|
|
1144
|
+
path,
|
|
1145
|
+
)
|
|
1888
1146
|
const bare =
|
|
1147
|
+
schema?.params === undefined &&
|
|
1889
1148
|
schema?.body === undefined &&
|
|
1890
1149
|
schema?.query === undefined &&
|
|
1891
1150
|
idempotent === undefined &&
|
|
@@ -1902,6 +1161,33 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1902
1161
|
isContextlessNoArgArrow(handler),
|
|
1903
1162
|
)
|
|
1904
1163
|
: undefined
|
|
1164
|
+
const contextless = bare && this.aroundHooks.length === 0 && isContextlessNoArgArrow(handler)
|
|
1165
|
+
const lane = bare
|
|
1166
|
+
? "bare"
|
|
1167
|
+
: schema?.body !== undefined &&
|
|
1168
|
+
schema.query === undefined &&
|
|
1169
|
+
schema.params === undefined &&
|
|
1170
|
+
this.derives.length === 0 &&
|
|
1171
|
+
this.beforeHandleHooks.length === 0 &&
|
|
1172
|
+
this.afterHandleHooks.length === 0 &&
|
|
1173
|
+
this.onErrorHooks.length === 0
|
|
1174
|
+
? "body"
|
|
1175
|
+
: schema?.body === undefined &&
|
|
1176
|
+
schema?.query !== undefined &&
|
|
1177
|
+
schema.params === undefined &&
|
|
1178
|
+
this.derives.length === 0 &&
|
|
1179
|
+
this.beforeHandleHooks.length === 0 &&
|
|
1180
|
+
this.afterHandleHooks.length === 0 &&
|
|
1181
|
+
this.onErrorHooks.length === 0
|
|
1182
|
+
? "query"
|
|
1183
|
+
: "lifecycle"
|
|
1184
|
+
const execution = this.compileExecutionPlan(
|
|
1185
|
+
lane,
|
|
1186
|
+
contextless,
|
|
1187
|
+
this.aroundHooks.length > 0,
|
|
1188
|
+
ledgered !== undefined,
|
|
1189
|
+
fusedWeb,
|
|
1190
|
+
)
|
|
1905
1191
|
const entry: RouteEntry = {
|
|
1906
1192
|
// (context: never) => unknown -> InternalHandler: the framework invokes it
|
|
1907
1193
|
// with the concrete RawContext the typed handler expects, so this is sound.
|
|
@@ -1909,7 +1195,6 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1909
1195
|
schema,
|
|
1910
1196
|
idempotent,
|
|
1911
1197
|
ledgered,
|
|
1912
|
-
fusedWeb,
|
|
1913
1198
|
derives: [...this.derives],
|
|
1914
1199
|
decorations: routeDecorations,
|
|
1915
1200
|
hasDecorations,
|
|
@@ -1917,141 +1202,217 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1917
1202
|
afterHandle: [...this.afterHandleHooks],
|
|
1918
1203
|
onError: [...this.onErrorHooks],
|
|
1919
1204
|
around: [...this.aroundHooks],
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
// `routeAndRun`, while the inner validation/derive/before/after/onError lifecycle remains empty.
|
|
1923
|
-
// Most GET/no-schema routes qualify.
|
|
1924
|
-
bare,
|
|
1925
|
-
contextlessBare: bare && this.aroundHooks.length === 0 && isContextlessNoArgArrow(handler),
|
|
1926
|
-
bodyOnly:
|
|
1927
|
-
schema?.body !== undefined &&
|
|
1928
|
-
schema.query === undefined &&
|
|
1929
|
-
this.derives.length === 0 &&
|
|
1930
|
-
this.beforeHandleHooks.length === 0 &&
|
|
1931
|
-
this.afterHandleHooks.length === 0 &&
|
|
1932
|
-
this.onErrorHooks.length === 0,
|
|
1933
|
-
queryOnly:
|
|
1934
|
-
schema?.body === undefined &&
|
|
1935
|
-
schema?.query !== undefined &&
|
|
1936
|
-
this.derives.length === 0 &&
|
|
1937
|
-
this.beforeHandleHooks.length === 0 &&
|
|
1938
|
-
this.afterHandleHooks.length === 0 &&
|
|
1939
|
-
this.onErrorHooks.length === 0,
|
|
1940
|
-
}
|
|
1941
|
-
this.router.add(method, path, entry)
|
|
1205
|
+
execution,
|
|
1206
|
+
}
|
|
1942
1207
|
const descriptor: RouteDescriptor = {
|
|
1943
1208
|
method,
|
|
1944
1209
|
path,
|
|
1945
1210
|
schema,
|
|
1946
1211
|
...(capabilities.length > 0 ? { capabilities } : {}),
|
|
1212
|
+
...(schema?.family === true ? { family: true } : {}),
|
|
1947
1213
|
}
|
|
1948
|
-
this.routeList.push(descriptor)
|
|
1949
1214
|
const routeAssurance: AssuranceDeclaration[] = [...this.activeAssurance, ...handlerAssurance]
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1215
|
+
// Inline `schema.assurance`: the route DECLARES its enforcement evidence adjacent to the handler, so an
|
|
1216
|
+
// in-handler-guarded route satisfies a policy `require:` clause without a `withRouteAssurance` middleware
|
|
1217
|
+
// rewrite. Each id becomes route-scoped `declared` evidence (invalid ids fail closed at registration).
|
|
1218
|
+
for (const id of schema?.assurance ?? []) {
|
|
1219
|
+
if (!validEvidenceId(id)) {
|
|
1220
|
+
throw new Error(
|
|
1221
|
+
`route assurance: invalid evidence id ${JSON.stringify(id)} on ${method} ${path} (use lowercase dot/dash segments)`,
|
|
1222
|
+
)
|
|
1223
|
+
}
|
|
1224
|
+
routeAssurance.push(Object.freeze({ id, source: "declared", scope: "plugin" }))
|
|
1958
1225
|
}
|
|
1959
|
-
|
|
1960
|
-
// durable command execution; that stronger evidence belongs to a command/outbox adapter.
|
|
1961
|
-
if (schema?.idempotency !== undefined) {
|
|
1226
|
+
if (schema?.body !== undefined) {
|
|
1962
1227
|
routeAssurance.push(
|
|
1963
1228
|
Object.freeze({
|
|
1964
|
-
id: NIFRA_ASSURANCE_IDS.
|
|
1965
|
-
source: "route-schema",
|
|
1966
|
-
scope: "plugin",
|
|
1967
|
-
}),
|
|
1968
|
-
)
|
|
1969
|
-
}
|
|
1970
|
-
this.rawEntries.push({
|
|
1971
|
-
method,
|
|
1972
|
-
path,
|
|
1973
|
-
entry,
|
|
1974
|
-
descriptor,
|
|
1975
|
-
assurance: Object.freeze(routeAssurance),
|
|
1976
|
-
})
|
|
1977
|
-
}
|
|
1978
|
-
|
|
1979
|
-
/** Resolve a route's declared idempotency into a pinned store + defaults, or `undefined` when off. */
|
|
1980
|
-
private resolveIdempotency(
|
|
1981
|
-
schema: RouteSchema | undefined,
|
|
1982
|
-
authenticated: boolean,
|
|
1983
|
-
): ResolvedIdempotency | undefined {
|
|
1984
|
-
const config = schema?.idempotency
|
|
1985
|
-
if (config === undefined) return undefined
|
|
1986
|
-
if (schema?.sse !== undefined) {
|
|
1987
|
-
throw new RouteConfigError(
|
|
1988
|
-
"INVALID_IDEMPOTENCY",
|
|
1989
|
-
"streaming/SSE responses cannot be used on an idempotency route",
|
|
1990
|
-
)
|
|
1991
|
-
}
|
|
1992
|
-
const store = config.store ?? this.defaultIdempotencyStore()
|
|
1993
|
-
if (config.scope === "durable" && store.durability !== "durable") {
|
|
1994
|
-
throw new RouteConfigError(
|
|
1995
|
-
"INVALID_IDEMPOTENCY",
|
|
1996
|
-
'durable idempotency requires a durable store (store.durability must be "durable")',
|
|
1997
|
-
)
|
|
1998
|
-
}
|
|
1999
|
-
const ttlMs = config.ttlMs ?? DEFAULT_IDEMPOTENCY_TTL_MS
|
|
2000
|
-
if (!Number.isSafeInteger(ttlMs) || ttlMs <= 0) {
|
|
2001
|
-
throw new RouteConfigError(
|
|
2002
|
-
"INVALID_IDEMPOTENCY",
|
|
2003
|
-
"idempotency ttlMs must be a finite positive number",
|
|
2004
|
-
)
|
|
2005
|
-
}
|
|
2006
|
-
const headerName = (config.headerName ?? DEFAULT_IDEMPOTENCY_HEADER).toLowerCase()
|
|
2007
|
-
try {
|
|
2008
|
-
new Headers().set(headerName, "probe")
|
|
2009
|
-
} catch {
|
|
2010
|
-
throw new RouteConfigError(
|
|
2011
|
-
"INVALID_IDEMPOTENCY",
|
|
2012
|
-
`invalid idempotency header name ${JSON.stringify(headerName)}`,
|
|
2013
|
-
)
|
|
2014
|
-
}
|
|
2015
|
-
const namespace = config.namespace
|
|
2016
|
-
if (namespace === undefined) {
|
|
2017
|
-
throw new RouteConfigError(
|
|
2018
|
-
"INVALID_IDEMPOTENCY",
|
|
2019
|
-
"idempotency namespace is required; use a principal resolver or an explicit shared scope",
|
|
1229
|
+
id: NIFRA_ASSURANCE_IDS.BODY_BOUNDED,
|
|
1230
|
+
source: "route-schema",
|
|
1231
|
+
scope: "plugin",
|
|
1232
|
+
}),
|
|
2020
1233
|
)
|
|
2021
1234
|
}
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
1235
|
+
// Declaring `schema.idempotency` is evidence for request replay only. It deliberately never proves
|
|
1236
|
+
// durable command execution; that stronger evidence belongs to a command/outbox adapter.
|
|
1237
|
+
if (schema?.idempotency !== undefined) {
|
|
1238
|
+
routeAssurance.push(
|
|
1239
|
+
Object.freeze({
|
|
1240
|
+
id: NIFRA_ASSURANCE_IDS.IDEMPOTENCY_KEY,
|
|
1241
|
+
source: "route-schema",
|
|
1242
|
+
scope: "plugin",
|
|
1243
|
+
}),
|
|
2026
1244
|
)
|
|
2027
1245
|
}
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
1246
|
+
return {
|
|
1247
|
+
method,
|
|
1248
|
+
path,
|
|
1249
|
+
pattern,
|
|
1250
|
+
entry,
|
|
1251
|
+
descriptor,
|
|
1252
|
+
assurance: Object.freeze(routeAssurance),
|
|
2033
1253
|
}
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
/** Collapse route-invariant lifecycle decisions into one runner at registration. The request path
|
|
1257
|
+
* performs no eligibility ladder: it supplies request state to this already-selected plan. */
|
|
1258
|
+
private compileExecutionPlan(
|
|
1259
|
+
lane: "bare" | "body" | "query" | "lifecycle",
|
|
1260
|
+
contextless: boolean,
|
|
1261
|
+
hasAround: boolean,
|
|
1262
|
+
hasLedger: boolean,
|
|
1263
|
+
fusedWeb: FusedWebRunner | undefined,
|
|
1264
|
+
): RouteExecutionPlan {
|
|
1265
|
+
if (contextless) {
|
|
1266
|
+
const run: RouteExecutionRunner = (
|
|
1267
|
+
runtime,
|
|
1268
|
+
entry,
|
|
1269
|
+
source,
|
|
1270
|
+
params,
|
|
1271
|
+
search,
|
|
1272
|
+
signal,
|
|
1273
|
+
budget,
|
|
1274
|
+
platform,
|
|
1275
|
+
finalize,
|
|
1276
|
+
wrapResponse,
|
|
1277
|
+
) =>
|
|
1278
|
+
runtime.runContextlessBare(
|
|
1279
|
+
entry,
|
|
1280
|
+
source,
|
|
1281
|
+
params,
|
|
1282
|
+
search,
|
|
1283
|
+
signal,
|
|
1284
|
+
budget,
|
|
1285
|
+
platform,
|
|
1286
|
+
finalize,
|
|
1287
|
+
wrapResponse,
|
|
1288
|
+
)
|
|
1289
|
+
return Object.freeze({ run, fusedWeb })
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
let inner: ContextRouteRunner
|
|
1293
|
+
switch (lane) {
|
|
1294
|
+
case "bare":
|
|
1295
|
+
inner = (runtime, entry, _source, ctx, finalize, wrapResponse) =>
|
|
1296
|
+
runtime.runBare(entry, ctx, finalize, wrapResponse)
|
|
1297
|
+
break
|
|
1298
|
+
case "body":
|
|
1299
|
+
inner = (runtime, entry, source, ctx, finalize, wrapResponse) =>
|
|
1300
|
+
runtime.runBodyOnly(entry, source, ctx, finalize, wrapResponse)
|
|
1301
|
+
break
|
|
1302
|
+
case "query":
|
|
1303
|
+
inner = (runtime, entry, _source, ctx, finalize, wrapResponse) =>
|
|
1304
|
+
runtime.runQueryOnly(entry, ctx, finalize, wrapResponse)
|
|
1305
|
+
break
|
|
1306
|
+
default:
|
|
1307
|
+
inner = (runtime, entry, source, ctx, finalize, wrapResponse) =>
|
|
1308
|
+
runtime.runLifecycle(entry, source, ctx, finalize, wrapResponse)
|
|
1309
|
+
}
|
|
1310
|
+
const execute: ContextRouteRunner = hasAround
|
|
1311
|
+
? (runtime, entry, source, ctx, finalize, wrapResponse) =>
|
|
1312
|
+
runtime.runWithAround(
|
|
1313
|
+
entry,
|
|
1314
|
+
ctx,
|
|
1315
|
+
() => inner(runtime, entry, source, ctx, finalize, wrapResponse),
|
|
1316
|
+
finalize,
|
|
1317
|
+
wrapResponse,
|
|
1318
|
+
)
|
|
1319
|
+
: inner
|
|
1320
|
+
const run: RouteExecutionRunner = (
|
|
1321
|
+
runtime,
|
|
1322
|
+
entry,
|
|
1323
|
+
source,
|
|
1324
|
+
params,
|
|
1325
|
+
search,
|
|
1326
|
+
signal,
|
|
1327
|
+
budget,
|
|
1328
|
+
platform,
|
|
1329
|
+
finalize,
|
|
1330
|
+
wrapResponse,
|
|
1331
|
+
) => {
|
|
1332
|
+
const ctx = new RequestContext(
|
|
1333
|
+
source,
|
|
1334
|
+
params,
|
|
1335
|
+
search,
|
|
1336
|
+
signal,
|
|
1337
|
+
budget,
|
|
1338
|
+
platform,
|
|
1339
|
+
runtime.maxBodyBytes,
|
|
2039
1340
|
)
|
|
1341
|
+
let ledger: RequestLedger | undefined
|
|
1342
|
+
// The runtime is always present when a route resolved a ledger (enforced at registration).
|
|
1343
|
+
const ledgerRuntime = runtime.effectLedgerRuntime
|
|
1344
|
+
if (hasLedger && ledgerRuntime !== undefined) {
|
|
1345
|
+
const resolved = entry.ledgered as ResolvedEffectLedger
|
|
1346
|
+
ledger = ledgerRuntime.create(resolved)
|
|
1347
|
+
ledgerRuntime.attach(ctx, ledger)
|
|
1348
|
+
}
|
|
1349
|
+
let outcome = execute(runtime, entry, source, ctx, finalize, wrapResponse)
|
|
1350
|
+
if (ledger !== undefined && ledgerRuntime !== undefined) {
|
|
1351
|
+
const active = ledger
|
|
1352
|
+
const resolved = entry.ledgered as ResolvedEffectLedger
|
|
1353
|
+
outcome = (outcome instanceof Promise ? outcome : Promise.resolve(outcome)).then((value) =>
|
|
1354
|
+
ledgerRuntime.settle(active, resolved, value, (fields) =>
|
|
1355
|
+
runtime.logger.error("effect ledger sink failed", fields),
|
|
1356
|
+
),
|
|
1357
|
+
)
|
|
1358
|
+
}
|
|
1359
|
+
return outcome
|
|
2040
1360
|
}
|
|
2041
|
-
return Object.freeze({
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
1361
|
+
return Object.freeze({ run, fusedWeb })
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
/** The idempotency lane's bridge back into the normal matched lanes, resolved to a concrete Response
|
|
1365
|
+
* (the lane buffers the body, then replays it through the route's real validation + handler). */
|
|
1366
|
+
private idempotencyRunLanes(
|
|
1367
|
+
buffered: RequestSource,
|
|
1368
|
+
platform: Platform | undefined,
|
|
1369
|
+
entry: RouteEntry,
|
|
1370
|
+
params: Record<string, string>,
|
|
1371
|
+
search: string | undefined,
|
|
1372
|
+
): Promise<Response> {
|
|
1373
|
+
return Promise.resolve(
|
|
1374
|
+
this.runMatchedLanes(
|
|
1375
|
+
buffered,
|
|
1376
|
+
platform,
|
|
1377
|
+
entry,
|
|
1378
|
+
params,
|
|
1379
|
+
search,
|
|
1380
|
+
toResponse,
|
|
1381
|
+
IDENTITY_RESPONSE,
|
|
1382
|
+
RESPONSE_TIMEOUT,
|
|
1383
|
+
false,
|
|
1384
|
+
),
|
|
1385
|
+
)
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
/** @internal Symbol-keyed install seam for the `idempotency()` plugin. Off the public typed surface. */
|
|
1389
|
+
[INSTALL_IDEMPOTENCY](runtime: IdempotencyRuntime): void {
|
|
1390
|
+
this.assertConfigurable("idempotency()")
|
|
1391
|
+
this.idempotencyRuntime = runtime
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
/** @internal Symbol-keyed install seam for the `mcp()` plugin. Off the public typed surface. */
|
|
1395
|
+
[INSTALL_MCP](runtime: McpRuntime): void {
|
|
1396
|
+
this.assertConfigurable("mcp()")
|
|
1397
|
+
this.mcpRuntime = runtime
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
/** @internal Symbol-keyed install seam for the `nodeDirect()` plugin. Off the public typed surface. */
|
|
1401
|
+
[INSTALL_NODE_DIRECT](runtime: NodeOutcomeRuntime): void {
|
|
1402
|
+
this.assertConfigurable("nodeDirect()")
|
|
1403
|
+
this.nodeOutcomeRuntime = runtime
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
/** @internal Symbol-keyed install seam for the `streaming()` plugin. Off the public typed surface. */
|
|
1407
|
+
[INSTALL_SSE](runtime: SseRuntime): void {
|
|
1408
|
+
this.assertConfigurable("streaming()")
|
|
1409
|
+
this.sseRuntime = runtime
|
|
2048
1410
|
}
|
|
2049
1411
|
|
|
2050
|
-
/**
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
this.
|
|
2054
|
-
return this.lazyIdempotencyStore
|
|
1412
|
+
/** @internal Symbol-keyed install seam for the `websocket()` plugin. Off the public typed surface. */
|
|
1413
|
+
[INSTALL_WS](runtime: WsRuntime): void {
|
|
1414
|
+
this.assertConfigurable("websocket()")
|
|
1415
|
+
this.wsRuntime = runtime
|
|
2055
1416
|
}
|
|
2056
1417
|
|
|
2057
1418
|
/**
|
|
@@ -2074,6 +1435,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2074
1435
|
* those on the parent).
|
|
2075
1436
|
*/
|
|
2076
1437
|
merge<R2 extends Registry, Ctx2>(other: Server<R2, Ctx2>): Server<R & R2, Ctx> {
|
|
1438
|
+
this.assertConfigurable("merge()")
|
|
2077
1439
|
const source = other as unknown as Server<Registry, EmptyContext>
|
|
2078
1440
|
if (source.wsRouteCount > 0) {
|
|
2079
1441
|
throw new RouteConfigError(
|
|
@@ -2081,11 +1443,18 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2081
1443
|
"merge() does not carry WebSocket routes — register .ws() routes on the parent server",
|
|
2082
1444
|
)
|
|
2083
1445
|
}
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
1446
|
+
this.catalog.addBatch(source.catalog.entries().map((route) => this.bindFusedRuntime(route)))
|
|
1447
|
+
// Resolved idempotency/ledger route entries carry their own store/sink configuration, while the
|
|
1448
|
+
// runtime object supplies the generic execution machinery. Preserve a group's installed runtime
|
|
1449
|
+
// when the parent has none so merging cannot silently disable a safety lane. If the parent already
|
|
1450
|
+
// has a runtime, either implementation can execute every resolved entry because route-specific
|
|
1451
|
+
// options were pinned during registration.
|
|
1452
|
+
this.idempotencyRuntime ??= source.idempotencyRuntime
|
|
1453
|
+
this.effectLedgerRuntime ??= source.effectLedgerRuntime
|
|
1454
|
+
this.mcpRuntime ??= source.mcpRuntime
|
|
1455
|
+
this.nodeOutcomeRuntime ??= source.nodeOutcomeRuntime
|
|
1456
|
+
this.sseRuntime ??= source.sseRuntime
|
|
1457
|
+
this.wsRuntime ??= source.wsRuntime
|
|
2089
1458
|
this.onRequestHooks.push(...source.onRequestHooks)
|
|
2090
1459
|
this.onResponseHooks.push(...source.onResponseHooks)
|
|
2091
1460
|
this.onResponseFinalizedHooks.push(...source.onResponseFinalizedHooks)
|
|
@@ -2095,6 +1464,25 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2095
1464
|
return this as unknown as Server<R & R2, Ctx>
|
|
2096
1465
|
}
|
|
2097
1466
|
|
|
1467
|
+
/** A fused renderer closes over runtime services to keep its seven-argument JSC fast path. Merging
|
|
1468
|
+
* rebinds that closure once to the executing server; generic plans already receive the runtime. */
|
|
1469
|
+
private bindFusedRuntime(route: CatalogRoute): CatalogRoute {
|
|
1470
|
+
const { entry } = route
|
|
1471
|
+
if (entry.execution.fusedWeb === undefined) return route
|
|
1472
|
+
const fusedWeb = this.buildFusedWeb(
|
|
1473
|
+
entry.handler,
|
|
1474
|
+
entry.hasDecorations ? entry.decorations : undefined,
|
|
1475
|
+
isContextlessNoArgArrow(entry.handler),
|
|
1476
|
+
)
|
|
1477
|
+
return {
|
|
1478
|
+
...route,
|
|
1479
|
+
entry: {
|
|
1480
|
+
...entry,
|
|
1481
|
+
execution: Object.freeze({ ...entry.execution, fusedWeb }),
|
|
1482
|
+
},
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1485
|
+
|
|
2098
1486
|
/**
|
|
2099
1487
|
* Enumerate the registered routes (method, path, input schemas), in registration
|
|
2100
1488
|
* order. Powers `toOpenAPI` and other introspection; the router trie itself no
|
|
@@ -2102,10 +1490,11 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2102
1490
|
*/
|
|
2103
1491
|
routes(): ReadonlyArray<RouteDescriptor> {
|
|
2104
1492
|
if (this.activeAssurance.length === 0 && this.globalAssurance.length === 0) {
|
|
2105
|
-
|
|
2106
|
-
|
|
1493
|
+
if (!this.catalog.hasAssurance()) {
|
|
1494
|
+
return this.catalog.routeDescriptors()
|
|
1495
|
+
}
|
|
2107
1496
|
}
|
|
2108
|
-
return this.
|
|
1497
|
+
return this.catalog.entries().map(({ method, path, descriptor, assurance }) => {
|
|
2109
1498
|
const effective = assuranceEvidenceFor([...assurance, ...this.globalAssurance], method, path)
|
|
2110
1499
|
return effective.length > 0 ? { ...descriptor, assurance: effective } : descriptor
|
|
2111
1500
|
})
|
|
@@ -2136,7 +1525,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2136
1525
|
platform?: Platform<EnvOf<Ctx>>,
|
|
2137
1526
|
): MaybePromise<Response> {
|
|
2138
1527
|
// Non-`async` on purpose: `dispatch` may return a `Response` *synchronously* (the bare-route fast
|
|
2139
|
-
// path,
|
|
1528
|
+
// path, selected by the compiled execution plan), and an `async fetch` would wrap every such result in a redundant
|
|
2140
1529
|
// promise + microtask. Returning `Response | Promise<Response>` matches Web/edge handlers, while
|
|
2141
1530
|
// `await app.fetch(...)` continues to work exactly as before.
|
|
2142
1531
|
const outcome = this.dispatch<Response>(
|
|
@@ -2249,6 +1638,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2249
1638
|
resolveNodeSource(
|
|
2250
1639
|
source: RequestSource,
|
|
2251
1640
|
platform?: Platform<EnvOf<Ctx>>,
|
|
1641
|
+
suppliedRuntime?: NodeOutcomeRuntime,
|
|
2252
1642
|
): MaybePromise<NodeServeOutcome> {
|
|
2253
1643
|
// onResponse hooks transform a Response, and the capacity gate wraps the Web response path — both
|
|
2254
1644
|
// force the Web path here (the gated `fetchSource` admits/sheds/releases); wrap its result.
|
|
@@ -2262,15 +1652,22 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2262
1652
|
? response.then((settled) => ({ kind: "response", response: settled }))
|
|
2263
1653
|
: { kind: "response", response }
|
|
2264
1654
|
}
|
|
2265
|
-
// May resolve **synchronously** for a bare route + sync handler
|
|
1655
|
+
// May resolve **synchronously** for a compiled bare route + sync handler - the `@nifrajs/node`
|
|
2266
1656
|
// adapter `await`s the result, so it transparently handles either; the sync case allocates no promise
|
|
2267
1657
|
// at all on the Node hot path.
|
|
1658
|
+
const runtime = suppliedRuntime ?? this.nodeOutcomeRuntime
|
|
1659
|
+
if (runtime === undefined) {
|
|
1660
|
+
throw new FrameworkError(
|
|
1661
|
+
"NODE_DIRECT_RUNTIME_MISSING",
|
|
1662
|
+
"resolveNode() needs the Node-direct renderer. Normal @nifrajs/node serving installs it automatically; direct callers should add `.use(nodeDirect())` (from `@nifrajs/core/node-direct`).",
|
|
1663
|
+
)
|
|
1664
|
+
}
|
|
2268
1665
|
return this.dispatch<NodeServeOutcome>(
|
|
2269
1666
|
source,
|
|
2270
1667
|
platform,
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
|
|
1668
|
+
runtime.toOutcome,
|
|
1669
|
+
runtime.fromResponse,
|
|
1670
|
+
runtime.timeout,
|
|
2274
1671
|
false,
|
|
2275
1672
|
)
|
|
2276
1673
|
}
|
|
@@ -2294,11 +1691,12 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2294
1691
|
if (!match.found) return WS_PASS // upgrade header, no WS route here → normal routing decides
|
|
2295
1692
|
// Inspect only captured values for escapes. Scanning the full pathname repeated work the router
|
|
2296
1693
|
// already did and made every plain dynamic route pay for unrelated static path bytes.
|
|
2297
|
-
const params = match.params === EMPTY_PARAMS ? match.params :
|
|
1694
|
+
const params = match.params === EMPTY_PARAMS ? match.params : decodeRouteParams(match.params)
|
|
2298
1695
|
if (params === null) return { kind: "reject", response: jsonError(400, "malformed_path") }
|
|
2299
1696
|
const handler = match.payload.handler
|
|
2300
|
-
// Non-null: wsRouteCount > 0 ⇒ ws() ran ⇒ the runtime
|
|
1697
|
+
// Non-null: wsRouteCount > 0 ⇒ ws() ran ⇒ `.use(websocket())` installed the runtime + registry.
|
|
2301
1698
|
const pubsub = this.topics as TopicRegistry
|
|
1699
|
+
const attach = (this.wsRuntime as WsRuntime).attach
|
|
2302
1700
|
// CSWSH guard, before any per-connection work or the user's upgrade(): reject a disallowed
|
|
2303
1701
|
// Origin with 403. Browsers don't CORS-protect WS handshakes but do send cookies, so this
|
|
2304
1702
|
// blocks cross-site authenticated sockets when the route opts in via `allowedOrigins`.
|
|
@@ -2317,7 +1715,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2317
1715
|
return { kind: "reject", response: jsonError(403, "forbidden_origin") }
|
|
2318
1716
|
}
|
|
2319
1717
|
if (handler.upgrade === undefined) {
|
|
2320
|
-
return { kind: "upgrade", handler, data: undefined, pubsub }
|
|
1718
|
+
return { kind: "upgrade", handler, data: undefined, pubsub, attach }
|
|
2321
1719
|
}
|
|
2322
1720
|
const upgradeSignal = getNeverAbortSignal()
|
|
2323
1721
|
const ctx = new RequestContext(
|
|
@@ -2332,7 +1730,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2332
1730
|
const settle = (value: unknown): WebSocketUpgradeOutcome =>
|
|
2333
1731
|
value instanceof Response
|
|
2334
1732
|
? { kind: "reject", response: value }
|
|
2335
|
-
: { kind: "upgrade", handler, data: value, pubsub }
|
|
1733
|
+
: { kind: "upgrade", handler, data: value, pubsub, attach }
|
|
2336
1734
|
try {
|
|
2337
1735
|
const result = handler.upgrade(ctx as unknown as WebSocketContext<EnvOf<Ctx>>)
|
|
2338
1736
|
return result instanceof Promise
|
|
@@ -2354,7 +1752,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2354
1752
|
server: BunUpgradeServer,
|
|
2355
1753
|
): MaybePromise<Response | undefined> {
|
|
2356
1754
|
const handle = (o: WebSocketUpgradeOutcome): MaybePromise<Response | undefined> => {
|
|
2357
|
-
if (o.kind === "pass")
|
|
1755
|
+
if (o.kind === "pass")
|
|
1756
|
+
return this.fetch(req, bunPeerPlatform(server, req) as Platform<EnvOf<Ctx>>)
|
|
2358
1757
|
if (o.kind === "reject") return o.response
|
|
2359
1758
|
return server.upgrade(req, { data: { handler: o.handler, data: o.data } })
|
|
2360
1759
|
? undefined
|
|
@@ -2371,6 +1770,16 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2371
1770
|
* produces the 503. The Web `fetch` and `resolveNode` are thin callers over this one routing +
|
|
2372
1771
|
* context + lifecycle implementation — no duplication across the trust boundary.
|
|
2373
1772
|
*/
|
|
1773
|
+
/** Apply the `clientIp` trust declaration to the adapter's raw socket peer, returning a platform
|
|
1774
|
+
* whose `clientIp` is the derived caller. Only called when a trust declaration is configured. */
|
|
1775
|
+
private deriveClientIp(
|
|
1776
|
+
source: RequestSource,
|
|
1777
|
+
platform: Platform | undefined,
|
|
1778
|
+
): Platform | undefined {
|
|
1779
|
+
const derived = resolveClientIp(platform?.clientIp, requestOf(source), this.clientIpTrust)
|
|
1780
|
+
return { ...platform, clientIp: derived }
|
|
1781
|
+
}
|
|
1782
|
+
|
|
2374
1783
|
private dispatch<T>(
|
|
2375
1784
|
source: RequestSource,
|
|
2376
1785
|
platform: Platform | undefined,
|
|
@@ -2381,12 +1790,17 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2381
1790
|
// `Response` (`T = Response` there by construction; the node path always passes false).
|
|
2382
1791
|
webFast: boolean,
|
|
2383
1792
|
): MaybePromise<T> {
|
|
1793
|
+
// Resolve the trust declaration into the platform's `clientIp` ONCE, here at the shared funnel, so
|
|
1794
|
+
// `c.clientIp` (and every hook/derive downstream) sees the derived caller. No config ⇒ the raw
|
|
1795
|
+
// socket peer the adapter supplied passes through untouched (a one-property no-op on the hot path).
|
|
1796
|
+
const resolved =
|
|
1797
|
+
this.clientIpTrust === undefined ? platform : this.deriveClientIp(source, platform)
|
|
2384
1798
|
// onRequest hooks may be async, so a hooked app takes the async path; with no hooks (the common
|
|
2385
1799
|
// case) routing stays synchronous, letting a bare route resolve with no lifecycle promise at all.
|
|
2386
1800
|
if (this.onRequestHooks.length === 0) {
|
|
2387
|
-
return this.routeAndRun(source,
|
|
1801
|
+
return this.routeAndRun(source, resolved, finalize, wrapResponse, onTimeout, webFast)
|
|
2388
1802
|
}
|
|
2389
|
-
return this.runWithOnRequest(source,
|
|
1803
|
+
return this.runWithOnRequest(source, resolved, finalize, wrapResponse, onTimeout, webFast)
|
|
2390
1804
|
}
|
|
2391
1805
|
|
|
2392
1806
|
/**
|
|
@@ -2475,7 +1889,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2475
1889
|
|
|
2476
1890
|
/**
|
|
2477
1891
|
* Route → build context → run. Synchronous through to the handler for a **bare** route
|
|
2478
|
-
* (
|
|
1892
|
+
* (selected by its compiled plan), so a sync handler produces its result with zero promise allocations;
|
|
2479
1893
|
* routes with validation/hooks keep the full async {@link runLifecycle}, unchanged.
|
|
2480
1894
|
*/
|
|
2481
1895
|
private routeAndRun<T>(
|
|
@@ -2487,7 +1901,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2487
1901
|
webFast: boolean,
|
|
2488
1902
|
): MaybePromise<T> {
|
|
2489
1903
|
const url = urlPartsOf(source.url)
|
|
2490
|
-
const match = this.
|
|
1904
|
+
const match = this.catalog.find(source.method, url.pathname)
|
|
2491
1905
|
if (!match.found) {
|
|
2492
1906
|
if (match.reason === "method-not-allowed") {
|
|
2493
1907
|
return wrapResponse(
|
|
@@ -2499,7 +1913,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2499
1913
|
|
|
2500
1914
|
// Inspect only captured values for escapes. Scanning the full pathname repeated work the router
|
|
2501
1915
|
// already did and made every plain dynamic route pay for unrelated static path bytes.
|
|
2502
|
-
const params = match.params === EMPTY_PARAMS ? match.params :
|
|
1916
|
+
const params = match.params === EMPTY_PARAMS ? match.params : decodeRouteParams(match.params)
|
|
2503
1917
|
if (params === null) {
|
|
2504
1918
|
return wrapResponse(jsonError(400, "malformed_path"))
|
|
2505
1919
|
}
|
|
@@ -2531,8 +1945,22 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2531
1945
|
): MaybePromise<T> {
|
|
2532
1946
|
// An idempotency route runs its dedupe lane first; on a fresh key it delegates to the normal lanes
|
|
2533
1947
|
// (with the body buffered). All non-idempotent routes skip straight to the lanes — no added cost.
|
|
2534
|
-
|
|
2535
|
-
|
|
1948
|
+
// The runtime is always present when a route resolved idempotency (enforced at registration).
|
|
1949
|
+
if (entry.idempotent !== undefined && this.idempotencyRuntime !== undefined) {
|
|
1950
|
+
return this.idempotencyRuntime.run(
|
|
1951
|
+
entry.idempotent,
|
|
1952
|
+
requestOf(source),
|
|
1953
|
+
platform,
|
|
1954
|
+
entry,
|
|
1955
|
+
params,
|
|
1956
|
+
search,
|
|
1957
|
+
wrapResponse,
|
|
1958
|
+
{
|
|
1959
|
+
maxBodyBytes: this.maxBodyBytes,
|
|
1960
|
+
runLanes: (buffered, plat, ent, prm, srch) =>
|
|
1961
|
+
this.idempotencyRunLanes(buffered, plat, ent as RouteEntry, prm, srch),
|
|
1962
|
+
},
|
|
1963
|
+
)
|
|
2536
1964
|
}
|
|
2537
1965
|
return this.runMatchedLanes(
|
|
2538
1966
|
source,
|
|
@@ -2547,131 +1975,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2547
1975
|
)
|
|
2548
1976
|
}
|
|
2549
1977
|
|
|
2550
|
-
/**
|
|
2551
|
-
* Idempotency dedupe lane. Reads the `Idempotency-Key`, fingerprints the request, and consults the
|
|
2552
|
-
* store: a fresh key runs the handler (via {@link runMatchedLanes} with the body buffered) and stores
|
|
2553
|
-
* the response; a repeat key replays it without re-running; a key reused with a different body or an
|
|
2554
|
-
* in-flight duplicate is rejected. Every branch materializes a `Response`, then adapts to `T` via the
|
|
2555
|
-
* caller's `wrapResponse`, so it works on the Web fetch path and the Node-native outcome path alike.
|
|
2556
|
-
*/
|
|
2557
|
-
private async runIdempotent<T>(
|
|
2558
|
-
source: RequestSource,
|
|
2559
|
-
platform: Platform | undefined,
|
|
2560
|
-
entry: RouteEntry,
|
|
2561
|
-
params: Record<string, string>,
|
|
2562
|
-
search: string | undefined,
|
|
2563
|
-
wrapResponse: (response: Response) => T,
|
|
2564
|
-
): Promise<T> {
|
|
2565
|
-
const config = entry.idempotent as ResolvedIdempotency
|
|
2566
|
-
const req = requestOf(source)
|
|
2567
|
-
const key = req.headers.get(config.headerName)
|
|
2568
|
-
if (key === null || !validIdempotencyKey(key)) {
|
|
2569
|
-
return wrapResponse(jsonError(400, "idempotency_key_required"))
|
|
2570
|
-
}
|
|
2571
|
-
const namespace =
|
|
2572
|
-
typeof config.namespace === "function"
|
|
2573
|
-
? await config.namespace(req.clone(), platform)
|
|
2574
|
-
: config.namespace
|
|
2575
|
-
if (!validIdempotencyNamespace(namespace)) {
|
|
2576
|
-
return wrapResponse(jsonError(500, "idempotency_namespace_invalid"))
|
|
2577
|
-
}
|
|
2578
|
-
const read = await readBoundedBytes(req, this.maxBodyBytes)
|
|
2579
|
-
if (!read.ok) {
|
|
2580
|
-
return wrapResponse(
|
|
2581
|
-
jsonError(read.status, read.status === 413 ? "payload_too_large" : "bad_request"),
|
|
2582
|
-
)
|
|
2583
|
-
}
|
|
2584
|
-
const url = urlPartsOf(req.url)
|
|
2585
|
-
const canonicalBody = canonicalizeIdempotencyBody(read.bytes, req.headers.get("content-type"))
|
|
2586
|
-
const fingerprint = await computeIdempotencyFingerprint(
|
|
2587
|
-
req.method,
|
|
2588
|
-
`${url.pathname}${url.search}`,
|
|
2589
|
-
canonicalBody,
|
|
2590
|
-
req.headers.get("content-type") ?? "",
|
|
2591
|
-
)
|
|
2592
|
-
|
|
2593
|
-
const begin = await config.store.begin({ namespace, key, fingerprint, ttlMs: config.ttlMs })
|
|
2594
|
-
if (begin.state === "replay") {
|
|
2595
|
-
return wrapResponse(responseFromStored(begin.response, { maxBytes: config.maxResponseBytes }))
|
|
2596
|
-
}
|
|
2597
|
-
if (begin.state === "mismatch") return wrapResponse(jsonError(409, "idempotency_key_reused"))
|
|
2598
|
-
if (begin.state === "in-flight") {
|
|
2599
|
-
return wrapResponse(jsonError(409, "idempotency_in_progress", { "Retry-After": "1" }))
|
|
2600
|
-
}
|
|
2601
|
-
if (begin.state === "capacity") {
|
|
2602
|
-
return wrapResponse(jsonError(503, "idempotency_store_capacity", { "Retry-After": "1" }))
|
|
2603
|
-
}
|
|
2604
|
-
const reservation = begin.reservation
|
|
2605
|
-
|
|
2606
|
-
// Fresh key: re-expose the buffered body as a Request so the normal lanes can read + validate it,
|
|
2607
|
-
// then run those lanes to a concrete Response.
|
|
2608
|
-
const bufferedInit: RequestInit = {
|
|
2609
|
-
method: req.method,
|
|
2610
|
-
headers: req.headers,
|
|
2611
|
-
signal: req.signal,
|
|
2612
|
-
}
|
|
2613
|
-
if (read.bytes.byteLength > 0)
|
|
2614
|
-
bufferedInit.body = read.bytes as NonNullable<RequestInit["body"]>
|
|
2615
|
-
const buffered: RequestSource = new Request(req.url, bufferedInit)
|
|
2616
|
-
let response: Response
|
|
2617
|
-
try {
|
|
2618
|
-
response = await this.runMatchedLanes(
|
|
2619
|
-
buffered,
|
|
2620
|
-
platform,
|
|
2621
|
-
entry,
|
|
2622
|
-
params,
|
|
2623
|
-
search,
|
|
2624
|
-
toResponse,
|
|
2625
|
-
IDENTITY_RESPONSE,
|
|
2626
|
-
RESPONSE_TIMEOUT,
|
|
2627
|
-
false,
|
|
2628
|
-
)
|
|
2629
|
-
} catch (err) {
|
|
2630
|
-
// The lanes never throw (errors resolve to a 500 Response), but stay fail-safe: release the
|
|
2631
|
-
// reservation so a retry isn't wedged, then re-throw.
|
|
2632
|
-
await config.store.abandon({ namespace, key, reservation })
|
|
2633
|
-
throw err
|
|
2634
|
-
}
|
|
2635
|
-
// Cache only successful responses; release the key on any error so the client can retry.
|
|
2636
|
-
if (response.status >= 200 && response.status < 300) {
|
|
2637
|
-
let storedResponse: Awaited<ReturnType<typeof serializeResponse>>
|
|
2638
|
-
try {
|
|
2639
|
-
storedResponse = await serializeResponse(response, { maxBytes: config.maxResponseBytes })
|
|
2640
|
-
} catch (error) {
|
|
2641
|
-
if (!(error instanceof IdempotencyResponseTooLargeError)) throw error
|
|
2642
|
-
// The effect may already have happened, so never abandon and permit a duplicate execution.
|
|
2643
|
-
// Commit a small terminal response under the winning key and return that same response now.
|
|
2644
|
-
response = jsonError(507, "idempotency_response_too_large")
|
|
2645
|
-
try {
|
|
2646
|
-
storedResponse = await serializeResponse(response, {
|
|
2647
|
-
maxBytes: config.maxResponseBytes,
|
|
2648
|
-
})
|
|
2649
|
-
} catch (terminalError) {
|
|
2650
|
-
if (!(terminalError instanceof IdempotencyResponseTooLargeError)) throw terminalError
|
|
2651
|
-
// Even an intentionally tiny bound must remain truthful. An empty 507 preserves terminal
|
|
2652
|
-
// status + replay safety without silently storing more bytes than the route permits.
|
|
2653
|
-
response = new Response(null, { status: 507 })
|
|
2654
|
-
storedResponse = await serializeResponse(response, {
|
|
2655
|
-
maxBytes: config.maxResponseBytes,
|
|
2656
|
-
})
|
|
2657
|
-
}
|
|
2658
|
-
}
|
|
2659
|
-
const completed = await config.store.complete({
|
|
2660
|
-
namespace,
|
|
2661
|
-
key,
|
|
2662
|
-
reservation,
|
|
2663
|
-
response: storedResponse,
|
|
2664
|
-
})
|
|
2665
|
-
if (!completed) {
|
|
2666
|
-
return wrapResponse(jsonError(503, "idempotency_reservation_lost", { "Retry-After": "1" }))
|
|
2667
|
-
}
|
|
2668
|
-
} else {
|
|
2669
|
-
await config.store.abandon({ namespace, key, reservation })
|
|
2670
|
-
}
|
|
2671
|
-
return wrapResponse(response)
|
|
2672
|
-
}
|
|
2673
|
-
|
|
2674
|
-
/** Run a matched route through its selected execution lane (fused/bare/bodyOnly/queryOnly/lifecycle). */
|
|
1978
|
+
/** Supply request-specific deadline state to the route's precompiled execution plan. */
|
|
2675
1979
|
private runMatchedLanes<T>(
|
|
2676
1980
|
source: RequestSource,
|
|
2677
1981
|
platform: Platform | undefined,
|
|
@@ -2717,95 +2021,30 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2717
2021
|
controller === undefined
|
|
2718
2022
|
? getUnboundedRequestBudget()
|
|
2719
2023
|
: createRequestBudget({ deadline: admission!.deadline as number, signal })
|
|
2720
|
-
|
|
2721
|
-
|
|
2722
|
-
|
|
2723
|
-
|
|
2724
|
-
|
|
2725
|
-
|
|
2726
|
-
|
|
2727
|
-
|
|
2728
|
-
|
|
2729
|
-
|
|
2730
|
-
|
|
2731
|
-
|
|
2732
|
-
|
|
2733
|
-
|
|
2734
|
-
|
|
2735
|
-
|
|
2736
|
-
|
|
2737
|
-
|
|
2738
|
-
|
|
2739
|
-
|
|
2740
|
-
|
|
2741
|
-
|
|
2742
|
-
|
|
2743
|
-
|
|
2744
|
-
finalize,
|
|
2745
|
-
wrapResponse,
|
|
2746
|
-
)
|
|
2747
|
-
} else {
|
|
2748
|
-
// Build the context up front so `onError` always has it. Query, cookies, headers, and body helpers
|
|
2749
|
-
// stay lazy, but their accessors live on the prototype instead of allocating per-request closures.
|
|
2750
|
-
const ctx = new RequestContext(
|
|
2751
|
-
source,
|
|
2752
|
-
params,
|
|
2753
|
-
search,
|
|
2754
|
-
signal,
|
|
2755
|
-
budget,
|
|
2756
|
-
platform,
|
|
2757
|
-
this.maxBodyBytes,
|
|
2758
|
-
)
|
|
2759
|
-
// Ledgered routes (capabilities + effectLedger) carry a bounded per-request ledger; the
|
|
2760
|
-
// `useCapability` beacon appends to it, and the settle step below seals + sinks it. The route
|
|
2761
|
-
// was forced off the fused/contextless lanes at registration, so this branch always runs.
|
|
2762
|
-
let ledger: RequestLedger | undefined
|
|
2763
|
-
if (entry.ledgered !== undefined) {
|
|
2764
|
-
ledger = createRequestLedger({
|
|
2765
|
-
method: entry.ledgered.method,
|
|
2766
|
-
path: entry.ledgered.path,
|
|
2767
|
-
declared: entry.ledgered.declared,
|
|
2768
|
-
maxEntries: entry.ledgered.maxEntries,
|
|
2769
|
-
chain: entry.ledgered.chain,
|
|
2770
|
-
})
|
|
2771
|
-
attachEffectLedger(ctx, ledger)
|
|
2772
|
-
}
|
|
2773
|
-
|
|
2774
|
-
if (entry.around.length === 0) {
|
|
2775
|
-
outcome = entry.bare
|
|
2776
|
-
? this.runBare(entry, ctx, finalize, wrapResponse)
|
|
2777
|
-
: entry.bodyOnly
|
|
2778
|
-
? this.runBodyOnly(entry, source, ctx, finalize, wrapResponse)
|
|
2779
|
-
: entry.queryOnly
|
|
2780
|
-
? this.runQueryOnly(entry, ctx, finalize, wrapResponse)
|
|
2781
|
-
: this.runLifecycle(entry, source, ctx, finalize, wrapResponse)
|
|
2782
|
-
} else {
|
|
2783
|
-
outcome = this.runWithAround(
|
|
2784
|
-
entry,
|
|
2785
|
-
ctx,
|
|
2786
|
-
() =>
|
|
2787
|
-
entry.bare
|
|
2788
|
-
? this.runBare(entry, ctx, finalize, wrapResponse)
|
|
2789
|
-
: entry.bodyOnly
|
|
2790
|
-
? this.runBodyOnly(entry, source, ctx, finalize, wrapResponse)
|
|
2791
|
-
: entry.queryOnly
|
|
2792
|
-
? this.runQueryOnly(entry, ctx, finalize, wrapResponse)
|
|
2793
|
-
: this.runLifecycle(entry, source, ctx, finalize, wrapResponse),
|
|
2794
|
-
finalize,
|
|
2795
|
-
wrapResponse,
|
|
2796
|
-
)
|
|
2797
|
-
}
|
|
2798
|
-
// Settle step: seal the ledger and hand it to the sink BEFORE the response is released, on
|
|
2799
|
-
// success and error responses alike (partial work is exactly what an audit must capture). The
|
|
2800
|
-
// lanes above resolve handler errors to responses, so a plain resolve arm is sufficient.
|
|
2801
|
-
if (ledger !== undefined) {
|
|
2802
|
-
const resolved = entry.ledgered as ResolvedEffectLedger
|
|
2803
|
-
const active = ledger
|
|
2804
|
-
outcome = (outcome instanceof Promise ? outcome : Promise.resolve(outcome)).then((value) =>
|
|
2805
|
-
this.settleEffectLedger(active, resolved, value),
|
|
2806
|
-
)
|
|
2807
|
-
}
|
|
2808
|
-
}
|
|
2024
|
+
const plan = entry.execution
|
|
2025
|
+
const outcome: MaybePromise<T> =
|
|
2026
|
+
webFast && plan.fusedWeb !== undefined
|
|
2027
|
+
? (plan.fusedWeb(
|
|
2028
|
+
source,
|
|
2029
|
+
params,
|
|
2030
|
+
search,
|
|
2031
|
+
signal,
|
|
2032
|
+
budget,
|
|
2033
|
+
platform,
|
|
2034
|
+
false,
|
|
2035
|
+
) as MaybePromise<T>)
|
|
2036
|
+
: plan.run(
|
|
2037
|
+
this,
|
|
2038
|
+
entry,
|
|
2039
|
+
source,
|
|
2040
|
+
params,
|
|
2041
|
+
search,
|
|
2042
|
+
signal,
|
|
2043
|
+
budget,
|
|
2044
|
+
platform,
|
|
2045
|
+
finalize,
|
|
2046
|
+
wrapResponse,
|
|
2047
|
+
)
|
|
2809
2048
|
// The request timeout only bounds work that is actually pending — a synchronous (bare) result is
|
|
2810
2049
|
// already complete and can't time out, so it's returned as-is (no 503 race, no promise).
|
|
2811
2050
|
if (controller !== undefined && outcome instanceof Promise) {
|
|
@@ -2823,32 +2062,10 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2823
2062
|
return outcome
|
|
2824
2063
|
}
|
|
2825
2064
|
|
|
2826
|
-
/**
|
|
2827
|
-
|
|
2828
|
-
|
|
2829
|
-
|
|
2830
|
-
* the owned effect transaction, so a sink outage must not turn a successful effect into a retryable
|
|
2831
|
-
* 500 that can duplicate it.
|
|
2832
|
-
*/
|
|
2833
|
-
private async settleEffectLedger<T>(
|
|
2834
|
-
ledger: RequestLedger,
|
|
2835
|
-
resolved: ResolvedEffectLedger,
|
|
2836
|
-
value: T,
|
|
2837
|
-
): Promise<T> {
|
|
2838
|
-
const sealed = await ledger.seal()
|
|
2839
|
-
if (sealed.entries.length === 0) return value
|
|
2840
|
-
try {
|
|
2841
|
-
await resolved.sink(sealed)
|
|
2842
|
-
} catch (err) {
|
|
2843
|
-
// Token-only logging: the route pattern and the failure name — never entry payloads (there are
|
|
2844
|
-
// none) and never the request.
|
|
2845
|
-
this.logger.error("effect ledger sink failed", {
|
|
2846
|
-
method: resolved.method,
|
|
2847
|
-
path: resolved.path,
|
|
2848
|
-
name: err instanceof Error ? err.name : "Error",
|
|
2849
|
-
})
|
|
2850
|
-
}
|
|
2851
|
-
return value
|
|
2065
|
+
/** @internal Symbol-keyed install seam for the `effectLedger()` plugin. Off the public typed surface. */
|
|
2066
|
+
[INSTALL_EFFECT_LEDGER](runtime: EffectLedgerRuntime): void {
|
|
2067
|
+
this.assertConfigurable("effectLedger()")
|
|
2068
|
+
this.effectLedgerRuntime = runtime
|
|
2852
2069
|
}
|
|
2853
2070
|
|
|
2854
2071
|
/** The narrowest bare route: a syntactic `() => ...` handler cannot observe the context argument, so
|
|
@@ -2923,7 +2140,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2923
2140
|
}
|
|
2924
2141
|
|
|
2925
2142
|
/**
|
|
2926
|
-
* The synchronous fast path
|
|
2143
|
+
* The synchronous fast path selected by a route's execution plan: apply static decorations, call the
|
|
2927
2144
|
* handler, render the result — **no `await`** unless the handler itself returns a promise. It mirrors
|
|
2928
2145
|
* the bare slice of {@link runLifecycle} (which a bare route would otherwise no-op through) and shares
|
|
2929
2146
|
* {@link logRequestError}; a bare route has no `onError` hooks, so error handling is fully synchronous
|
|
@@ -2956,7 +2173,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2956
2173
|
/** Bare-route error rendering — identical to {@link runLifecycle}'s catch minus the (absent) onError
|
|
2957
2174
|
* loop: a thrown `Response` is returned as deliberate control flow; anything else is logged + 500. */
|
|
2958
2175
|
/**
|
|
2959
|
-
* Build a route's fused Web
|
|
2176
|
+
* Build a route's fused Web renderer. Composition happens once at
|
|
2960
2177
|
* registration; the returned closure is what every request to the route runs. Behavior is
|
|
2961
2178
|
* byte-identical to the generic `runBare`/`runContextlessBare` + `toResponse` pair — same
|
|
2962
2179
|
* decoration order, same error routing (thrown `Response` = control flow; anything else logs and
|
|
@@ -2967,7 +2184,6 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2967
2184
|
decorations: Record<PropertyKey, unknown> | undefined,
|
|
2968
2185
|
contextless: boolean,
|
|
2969
2186
|
): FusedWebRunner {
|
|
2970
|
-
const maxBodyBytes = this.maxBodyBytes
|
|
2971
2187
|
const logError = (err: unknown, ctx: RawContext): Response => {
|
|
2972
2188
|
if (err instanceof Response) return err
|
|
2973
2189
|
this.logRequestError(err, ctx)
|
|
@@ -2985,8 +2201,16 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2985
2201
|
return logError(
|
|
2986
2202
|
err,
|
|
2987
2203
|
nativeContext
|
|
2988
|
-
? RequestContext.native(source, params, maxBodyBytes)
|
|
2989
|
-
: new RequestContext(
|
|
2204
|
+
? RequestContext.native(source, params, this.maxBodyBytes)
|
|
2205
|
+
: new RequestContext(
|
|
2206
|
+
source,
|
|
2207
|
+
params,
|
|
2208
|
+
search,
|
|
2209
|
+
signal,
|
|
2210
|
+
budget,
|
|
2211
|
+
platform,
|
|
2212
|
+
this.maxBodyBytes,
|
|
2213
|
+
),
|
|
2990
2214
|
)
|
|
2991
2215
|
}
|
|
2992
2216
|
if (result instanceof Promise) {
|
|
@@ -2994,7 +2218,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2994
2218
|
logError(
|
|
2995
2219
|
err,
|
|
2996
2220
|
nativeContext
|
|
2997
|
-
? RequestContext.native(source, params, maxBodyBytes)
|
|
2221
|
+
? RequestContext.native(source, params, this.maxBodyBytes)
|
|
2998
2222
|
: new RequestContext(
|
|
2999
2223
|
source,
|
|
3000
2224
|
params,
|
|
@@ -3002,7 +2226,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
3002
2226
|
signal,
|
|
3003
2227
|
budget,
|
|
3004
2228
|
platform,
|
|
3005
|
-
maxBodyBytes,
|
|
2229
|
+
this.maxBodyBytes,
|
|
3006
2230
|
),
|
|
3007
2231
|
),
|
|
3008
2232
|
)
|
|
@@ -3012,8 +2236,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
3012
2236
|
}
|
|
3013
2237
|
return (source, params, search, signal, budget, platform, nativeContext) => {
|
|
3014
2238
|
const ctx = nativeContext
|
|
3015
|
-
? RequestContext.native(source, params, maxBodyBytes)
|
|
3016
|
-
: new RequestContext(source, params, search, signal, budget, platform, maxBodyBytes)
|
|
2239
|
+
? RequestContext.native(source, params, this.maxBodyBytes)
|
|
2240
|
+
: new RequestContext(source, params, search, signal, budget, platform, this.maxBodyBytes)
|
|
3017
2241
|
if (decorations !== undefined) Object.assign(ctx, decorations)
|
|
3018
2242
|
let result: unknown
|
|
3019
2243
|
try {
|
|
@@ -3487,8 +2711,17 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
3487
2711
|
wrapResponse: (response: Response) => T,
|
|
3488
2712
|
): Promise<T> {
|
|
3489
2713
|
try {
|
|
2714
|
+
if (entry.schema?.params !== undefined) {
|
|
2715
|
+
// Path params arrive as strings from routing; validate (and coerce) them at the boundary, before
|
|
2716
|
+
// the body/query, so a malformed `:id` is a 422 before any work runs. Raw `.validate` (no wrapper
|
|
2717
|
+
// alloc), same recovery-hook path as body/query.
|
|
2718
|
+
const validation = entry.schema.params["~standard"].validate(ctx.params)
|
|
2719
|
+
const result = validation instanceof Promise ? await validation : validation
|
|
2720
|
+
const paramsError = await this.applyLifecycleValidation(entry, result, ctx, "params")
|
|
2721
|
+
if (paramsError !== undefined) return wrapResponse(paramsError)
|
|
2722
|
+
}
|
|
3490
2723
|
if (entry.schema?.body !== undefined) {
|
|
3491
|
-
const bodyError = await this.readAndValidateBody(source, entry
|
|
2724
|
+
const bodyError = await this.readAndValidateBody(source, entry, ctx)
|
|
3492
2725
|
if (bodyError !== undefined) return wrapResponse(bodyError)
|
|
3493
2726
|
}
|
|
3494
2727
|
if (entry.schema?.query !== undefined) {
|
|
@@ -3499,8 +2732,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
3499
2732
|
queryObjectOf(ctx[CONTEXT_SEARCH]),
|
|
3500
2733
|
)
|
|
3501
2734
|
const result = validation instanceof Promise ? await validation : validation
|
|
3502
|
-
|
|
3503
|
-
|
|
2735
|
+
const queryError = await this.applyLifecycleValidation(entry, result, ctx, "query")
|
|
2736
|
+
if (queryError !== undefined) return wrapResponse(queryError)
|
|
3504
2737
|
}
|
|
3505
2738
|
|
|
3506
2739
|
// Context extensions: static decorations, then per-request derives.
|
|
@@ -3581,7 +2814,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
3581
2814
|
|
|
3582
2815
|
private async readAndValidateBody(
|
|
3583
2816
|
req: RequestSource,
|
|
3584
|
-
|
|
2817
|
+
entry: RouteEntry,
|
|
3585
2818
|
ctx: RawContext,
|
|
3586
2819
|
): Promise<Response | undefined> {
|
|
3587
2820
|
const contentType = headerOf(req, "content-type") ?? ""
|
|
@@ -3599,10 +2832,44 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
3599
2832
|
// fit a value schema; use a schema-less route + @nifrajs/uploads helpers for those.
|
|
3600
2833
|
return jsonError(415, "unsupported_media_type")
|
|
3601
2834
|
}
|
|
3602
|
-
const validation = schema["~standard"].validate(parsed)
|
|
2835
|
+
const validation = entry.schema!.body!["~standard"].validate(parsed)
|
|
3603
2836
|
const result = validation instanceof Promise ? await validation : validation
|
|
3604
|
-
|
|
3605
|
-
|
|
2837
|
+
return this.applyLifecycleValidation(entry, result, ctx, "body")
|
|
2838
|
+
}
|
|
2839
|
+
|
|
2840
|
+
/** Apply validation and its recovery hook on the generic lifecycle lane. Recovery is completed
|
|
2841
|
+
* before derives/beforeHandle run, matching the body-only and query-only execution lanes. */
|
|
2842
|
+
private async applyLifecycleValidation(
|
|
2843
|
+
entry: RouteEntry,
|
|
2844
|
+
result: StandardResult<unknown>,
|
|
2845
|
+
ctx: RawContext,
|
|
2846
|
+
kind: "body" | "query" | "params",
|
|
2847
|
+
): Promise<Response | undefined> {
|
|
2848
|
+
const assign = (value: unknown): void => {
|
|
2849
|
+
if (kind === "body") ctx.body = value
|
|
2850
|
+
else if (kind === "query") ctx.query = value
|
|
2851
|
+
else ctx.params = value as Record<string, string>
|
|
2852
|
+
}
|
|
2853
|
+
if (result.issues === undefined) {
|
|
2854
|
+
assign(result.value)
|
|
2855
|
+
return undefined
|
|
2856
|
+
}
|
|
2857
|
+
const hook = entry.schema?.onValidationError ?? this.defaultOnValidationError
|
|
2858
|
+
if (hook === undefined) return validationError(result.issues)
|
|
2859
|
+
const attempted = hook(result.issues, ctx as unknown as Context, kind)
|
|
2860
|
+
const recovery = attempted instanceof Promise ? await attempted : attempted
|
|
2861
|
+
if (recovery === undefined) return validationError(result.issues)
|
|
2862
|
+
if (recovery instanceof Response) return recovery
|
|
2863
|
+
const schema =
|
|
2864
|
+
kind === "body"
|
|
2865
|
+
? entry.schema?.body
|
|
2866
|
+
: kind === "query"
|
|
2867
|
+
? entry.schema?.query
|
|
2868
|
+
: entry.schema?.params
|
|
2869
|
+
const retried = schema!["~standard"].validate(recovery)
|
|
2870
|
+
const settled = retried instanceof Promise ? await retried : retried
|
|
2871
|
+
if (settled.issues !== undefined) return validationError(settled.issues)
|
|
2872
|
+
assign(settled.value)
|
|
3606
2873
|
return undefined
|
|
3607
2874
|
}
|
|
3608
2875
|
|
|
@@ -3624,12 +2891,70 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
3624
2891
|
return readBoundedJsonSource(req, this.maxBodyBytes)
|
|
3625
2892
|
}
|
|
3626
2893
|
|
|
2894
|
+
/** Adapt one route's compiled execution plan to Bun's already-matched request shape. Route
|
|
2895
|
+
* semantics remain in the plan; this closure only supplies native params and deadline fallback. */
|
|
2896
|
+
private compileBunNativeHandler(
|
|
2897
|
+
entry: RouteEntry,
|
|
2898
|
+
paramNames: readonly string[],
|
|
2899
|
+
fused: FusedWebRunner | undefined,
|
|
2900
|
+
signal: AbortSignal | undefined,
|
|
2901
|
+
budget: RequestBudget | undefined,
|
|
2902
|
+
): BunNativeHandler {
|
|
2903
|
+
if (paramNames.length === 0) {
|
|
2904
|
+
if (fused === undefined) {
|
|
2905
|
+
return (request) => this.fetchMatched(request, entry, EMPTY_PARAMS)
|
|
2906
|
+
}
|
|
2907
|
+
if (this.acceptInboundDeadlines) {
|
|
2908
|
+
return (request) =>
|
|
2909
|
+
request.headers.get(NIFRA_DEADLINE_HEADER) !== null
|
|
2910
|
+
? this.fetchMatched(request, entry, EMPTY_PARAMS)
|
|
2911
|
+
: fused(request, EMPTY_PARAMS, undefined, signal!, budget!, undefined, true)
|
|
2912
|
+
}
|
|
2913
|
+
return (request) => fused(request, EMPTY_PARAMS, undefined, signal!, budget!, undefined, true)
|
|
2914
|
+
}
|
|
2915
|
+
|
|
2916
|
+
const malformed =
|
|
2917
|
+
paramNames.length === 1
|
|
2918
|
+
? (params: Record<string, string>) => params[paramNames[0]!]?.includes("\uFFFD") === true
|
|
2919
|
+
: hasReplacementParam
|
|
2920
|
+
if (fused === undefined) {
|
|
2921
|
+
return (request) => {
|
|
2922
|
+
const params = (request as BunRequestWithParams).params ?? EMPTY_PARAMS
|
|
2923
|
+
if (malformed(params)) return this.fetchSource(request)
|
|
2924
|
+
return this.fetchMatched(request, entry, params)
|
|
2925
|
+
}
|
|
2926
|
+
}
|
|
2927
|
+
if (this.acceptInboundDeadlines) {
|
|
2928
|
+
return (request) => {
|
|
2929
|
+
const params = (request as BunRequestWithParams).params ?? EMPTY_PARAMS
|
|
2930
|
+
if (malformed(params)) return this.fetchSource(request)
|
|
2931
|
+
return request.headers.get(NIFRA_DEADLINE_HEADER) !== null
|
|
2932
|
+
? this.fetchMatched(request, entry, params)
|
|
2933
|
+
: fused(request, params, undefined, signal!, budget!, undefined, true)
|
|
2934
|
+
}
|
|
2935
|
+
}
|
|
2936
|
+
return (request) => {
|
|
2937
|
+
const params = (request as BunRequestWithParams).params ?? EMPTY_PARAMS
|
|
2938
|
+
if (malformed(params)) return this.fetchSource(request)
|
|
2939
|
+
return fused(request, params, undefined, signal!, budget!, undefined, true)
|
|
2940
|
+
}
|
|
2941
|
+
}
|
|
2942
|
+
|
|
3627
2943
|
/** Compile portable route registrations into Bun's native route table. Apps with request-rewrite
|
|
3628
2944
|
* hooks or WebSockets retain the single portable dispatcher because those features must run before
|
|
3629
2945
|
* route selection/upgrade. Named wildcards also stay on the fallback until Bun exposes their raw
|
|
3630
2946
|
* capture semantics; static and `:param` routes take the native lane. */
|
|
3631
2947
|
private buildBunNativeRoutes(): BunNativeRoutes | undefined {
|
|
3632
|
-
|
|
2948
|
+
// A `clientIp` trust declaration must run the resolver in `dispatch`, which the fused native lane
|
|
2949
|
+
// bypasses — so an app that declares trust routes through the fetch lane (where `c.clientIp`
|
|
2950
|
+
// resolves) instead of Bun's native table. The allocation-free default keeps native fusion.
|
|
2951
|
+
if (
|
|
2952
|
+
this.onRequestHooks.length > 0 ||
|
|
2953
|
+
this.wsRouteCount > 0 ||
|
|
2954
|
+
this.clientIpTrust !== undefined
|
|
2955
|
+
) {
|
|
2956
|
+
return undefined
|
|
2957
|
+
}
|
|
3633
2958
|
|
|
3634
2959
|
const routes: BunNativeRoutes = Object.create(null) as BunNativeRoutes
|
|
3635
2960
|
const mayUseFusedNative =
|
|
@@ -3642,143 +2967,22 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
3642
2967
|
const unboundedSignal = mayUseFusedNative ? getNeverAbortSignal() : undefined
|
|
3643
2968
|
const unboundedBudget = mayUseFusedNative ? getUnboundedRequestBudget() : undefined
|
|
3644
2969
|
let count = 0
|
|
3645
|
-
for (const { method, path, entry } of this.
|
|
3646
|
-
if (
|
|
2970
|
+
for (const { method, path, pattern, entry } of this.catalog.entries()) {
|
|
2971
|
+
if (pattern.segments.some((segment) => segment.kind === "wildcard")) continue
|
|
3647
2972
|
let methods = routes[path]
|
|
3648
2973
|
if (methods === undefined) {
|
|
3649
2974
|
methods = Object.create(null) as BunNativeMethodTable
|
|
3650
2975
|
routes[path] = methods
|
|
3651
2976
|
}
|
|
3652
|
-
const paramNames =
|
|
3653
|
-
|
|
3654
|
-
|
|
3655
|
-
|
|
3656
|
-
|
|
3657
|
-
|
|
3658
|
-
|
|
3659
|
-
|
|
3660
|
-
|
|
3661
|
-
// handler + Response.json; retaining that route-invariant branch cost ~7% on a dynamic GET.
|
|
3662
|
-
if (paramNames.length === 0) {
|
|
3663
|
-
methods[method] = this.acceptInboundDeadlines
|
|
3664
|
-
? (request) =>
|
|
3665
|
-
request.headers.get(NIFRA_DEADLINE_HEADER) !== null
|
|
3666
|
-
? this.fetchMatched(request, entry, EMPTY_PARAMS)
|
|
3667
|
-
: fused(
|
|
3668
|
-
request,
|
|
3669
|
-
EMPTY_PARAMS,
|
|
3670
|
-
undefined,
|
|
3671
|
-
unboundedSignal as AbortSignal,
|
|
3672
|
-
unboundedBudget as RequestBudget,
|
|
3673
|
-
undefined,
|
|
3674
|
-
true,
|
|
3675
|
-
)
|
|
3676
|
-
: (request) =>
|
|
3677
|
-
fused(
|
|
3678
|
-
request,
|
|
3679
|
-
EMPTY_PARAMS,
|
|
3680
|
-
undefined,
|
|
3681
|
-
unboundedSignal as AbortSignal,
|
|
3682
|
-
unboundedBudget as RequestBudget,
|
|
3683
|
-
undefined,
|
|
3684
|
-
true,
|
|
3685
|
-
)
|
|
3686
|
-
} else if (paramNames.length === 1) {
|
|
3687
|
-
const paramName = paramNames[0]!
|
|
3688
|
-
const handler = entry.handler
|
|
3689
|
-
const decorations = entry.hasDecorations ? entry.decorations : undefined
|
|
3690
|
-
const fail = (error: unknown, ctx: RawContext): Response => {
|
|
3691
|
-
if (error instanceof Response) return error
|
|
3692
|
-
this.logRequestError(error, ctx)
|
|
3693
|
-
return jsonError(500, "internal_error")
|
|
3694
|
-
}
|
|
3695
|
-
methods[method] = this.acceptInboundDeadlines
|
|
3696
|
-
? (request) => {
|
|
3697
|
-
const params = (request as BunRequestWithParams).params ?? EMPTY_PARAMS
|
|
3698
|
-
if (params[paramName]?.includes("\uFFFD") === true) return this.fetchSource(request)
|
|
3699
|
-
if (request.headers.get(NIFRA_DEADLINE_HEADER) !== null) {
|
|
3700
|
-
return this.fetchMatched(request, entry, params)
|
|
3701
|
-
}
|
|
3702
|
-
const ctx = RequestContext.native(request, params, this.maxBodyBytes)
|
|
3703
|
-
if (decorations !== undefined) Object.assign(ctx, decorations)
|
|
3704
|
-
let result: unknown
|
|
3705
|
-
try {
|
|
3706
|
-
result = handler(ctx)
|
|
3707
|
-
} catch (error) {
|
|
3708
|
-
return fail(error, ctx)
|
|
3709
|
-
}
|
|
3710
|
-
return result instanceof Promise
|
|
3711
|
-
? result.then(
|
|
3712
|
-
(value) => fusedRespond(value, ctx),
|
|
3713
|
-
(error) => fail(error, ctx),
|
|
3714
|
-
)
|
|
3715
|
-
: fusedRespond(result, ctx)
|
|
3716
|
-
}
|
|
3717
|
-
: (request) => {
|
|
3718
|
-
const params = (request as BunRequestWithParams).params ?? EMPTY_PARAMS
|
|
3719
|
-
if (params[paramName]?.includes("\uFFFD") === true) return this.fetchSource(request)
|
|
3720
|
-
const ctx = RequestContext.native(request, params, this.maxBodyBytes)
|
|
3721
|
-
if (decorations !== undefined) Object.assign(ctx, decorations)
|
|
3722
|
-
let result: unknown
|
|
3723
|
-
try {
|
|
3724
|
-
result = handler(ctx)
|
|
3725
|
-
} catch (error) {
|
|
3726
|
-
return fail(error, ctx)
|
|
3727
|
-
}
|
|
3728
|
-
return result instanceof Promise
|
|
3729
|
-
? result.then(
|
|
3730
|
-
(value) => fusedRespond(value, ctx),
|
|
3731
|
-
(error) => fail(error, ctx),
|
|
3732
|
-
)
|
|
3733
|
-
: fusedRespond(result, ctx)
|
|
3734
|
-
}
|
|
3735
|
-
} else {
|
|
3736
|
-
methods[method] = this.acceptInboundDeadlines
|
|
3737
|
-
? (request) => {
|
|
3738
|
-
const params = (request as BunRequestWithParams).params ?? EMPTY_PARAMS
|
|
3739
|
-
if (hasReplacementParam(params)) return this.fetchSource(request)
|
|
3740
|
-
return request.headers.get(NIFRA_DEADLINE_HEADER) !== null
|
|
3741
|
-
? this.fetchMatched(request, entry, params)
|
|
3742
|
-
: fused(
|
|
3743
|
-
request,
|
|
3744
|
-
params,
|
|
3745
|
-
undefined,
|
|
3746
|
-
unboundedSignal as AbortSignal,
|
|
3747
|
-
unboundedBudget as RequestBudget,
|
|
3748
|
-
undefined,
|
|
3749
|
-
true,
|
|
3750
|
-
)
|
|
3751
|
-
}
|
|
3752
|
-
: (request) => {
|
|
3753
|
-
const params = (request as BunRequestWithParams).params ?? EMPTY_PARAMS
|
|
3754
|
-
if (hasReplacementParam(params)) return this.fetchSource(request)
|
|
3755
|
-
return fused(
|
|
3756
|
-
request,
|
|
3757
|
-
params,
|
|
3758
|
-
undefined,
|
|
3759
|
-
unboundedSignal as AbortSignal,
|
|
3760
|
-
unboundedBudget as RequestBudget,
|
|
3761
|
-
undefined,
|
|
3762
|
-
true,
|
|
3763
|
-
)
|
|
3764
|
-
}
|
|
3765
|
-
}
|
|
3766
|
-
} else if (paramNames.length === 0) {
|
|
3767
|
-
methods[method] = (request) => this.fetchMatched(request, entry, EMPTY_PARAMS)
|
|
3768
|
-
} else if (paramNames.length === 1) {
|
|
3769
|
-
const paramName = paramNames[0]!
|
|
3770
|
-
methods[method] = (request) => {
|
|
3771
|
-
const params = (request as BunRequestWithParams).params ?? EMPTY_PARAMS
|
|
3772
|
-
if (params[paramName]?.includes("\uFFFD") === true) return this.fetchSource(request)
|
|
3773
|
-
return this.fetchMatched(request, entry, params)
|
|
3774
|
-
}
|
|
3775
|
-
} else {
|
|
3776
|
-
methods[method] = (request) => {
|
|
3777
|
-
const params = (request as BunRequestWithParams).params ?? EMPTY_PARAMS
|
|
3778
|
-
if (hasReplacementParam(params)) return this.fetchSource(request)
|
|
3779
|
-
return this.fetchMatched(request, entry, params)
|
|
3780
|
-
}
|
|
3781
|
-
}
|
|
2977
|
+
const paramNames = pattern.paramNames
|
|
2978
|
+
const fused = mayUseFusedNative ? entry.execution.fusedWeb : undefined
|
|
2979
|
+
methods[method] = this.compileBunNativeHandler(
|
|
2980
|
+
entry,
|
|
2981
|
+
paramNames,
|
|
2982
|
+
fused,
|
|
2983
|
+
unboundedSignal,
|
|
2984
|
+
unboundedBudget,
|
|
2985
|
+
)
|
|
3782
2986
|
count += 1
|
|
3783
2987
|
}
|
|
3784
2988
|
return count === 0 ? undefined : routes
|
|
@@ -3821,12 +3025,12 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
3821
3025
|
// `server` 2nd arg is how Bun exposes `upgrade`); otherwise the lean request-only fetch. The
|
|
3822
3026
|
// `websocket` handlers are one shared dispatcher — each connection's `ws.data.handler` is the
|
|
3823
3027
|
// matched route's handler, set by `server.upgrade`.
|
|
3824
|
-
// With WS routes, the dispatcher comes from the
|
|
3825
|
-
// wsRouteCount > 0 means ws() ran, and ws() requires the runtime at registration.
|
|
3028
|
+
// With WS routes, the dispatcher comes from the installed `.use(websocket())` runtime — non-null
|
|
3029
|
+
// because wsRouteCount > 0 means ws() ran, and ws() requires the runtime at registration.
|
|
3826
3030
|
const wsHandlers =
|
|
3827
3031
|
this.wsRouteCount === 0
|
|
3828
3032
|
? undefined
|
|
3829
|
-
: (
|
|
3033
|
+
: (this.wsRuntime as WsRuntime).bunHandlers(this.topics as TopicRegistry)
|
|
3830
3034
|
const reusePort = options?.reusePort === true
|
|
3831
3035
|
// Spread rather than pass `hostname: undefined` - Bun treats an explicit undefined as a value
|
|
3832
3036
|
// on some option paths, and omitting is what selects its 0.0.0.0 default.
|
|
@@ -3838,7 +3042,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
3838
3042
|
reusePort,
|
|
3839
3043
|
...bind,
|
|
3840
3044
|
...(nativeRoutes === undefined ? {} : { routes: nativeRoutes }),
|
|
3841
|
-
fetch: (req: Request) =>
|
|
3045
|
+
fetch: (req: Request, server) =>
|
|
3046
|
+
this.fetch(req, bunPeerPlatform(server, req) as Platform<EnvOf<Ctx>>),
|
|
3842
3047
|
})
|
|
3843
3048
|
: Bun.serve<BunWsData>({
|
|
3844
3049
|
port,
|
|
@@ -3858,6 +3063,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
3858
3063
|
},
|
|
3859
3064
|
})) as unknown as RunningServer
|
|
3860
3065
|
this.bunServer = running
|
|
3066
|
+
this.sealed = true
|
|
3861
3067
|
if (this.gracefulSignals) this.installSignalHandlers()
|
|
3862
3068
|
return running
|
|
3863
3069
|
}
|
|
@@ -4017,9 +3223,9 @@ export function toFetchHandler<Env = unknown>(
|
|
|
4017
3223
|
const pair = new Pair()
|
|
4018
3224
|
const server = pair[1]
|
|
4019
3225
|
server.accept()
|
|
4020
|
-
//
|
|
4021
|
-
//
|
|
4022
|
-
|
|
3226
|
+
// The outcome carries the installed runtime's `attach`, so `toFetchHandler` (a standalone
|
|
3227
|
+
// function, no access to the app's private runtime) wires the socket without a static WS import.
|
|
3228
|
+
outcome.attach(server, outcome.handler, outcome.data, {
|
|
4023
3229
|
openNow: true,
|
|
4024
3230
|
pubsub: outcome.pubsub,
|
|
4025
3231
|
})
|