@nifrajs/core 1.13.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 -35
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -31
- 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/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 +14 -4
- package/dist/server/contract.d.ts.map +1 -1
- package/dist/server/contract.js +8 -2
- 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 +107 -345
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +214 -831
- 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 +77 -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 -133
- package/src/mcp.ts +65 -0
- package/src/node-direct.ts +37 -0
- package/src/reflection.ts +7 -0
- package/src/server/client-ip.ts +60 -0
- package/src/server/context.ts +50 -2
- package/src/server/contract.ts +26 -7
- 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 +402 -1314
- 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,18 +15,12 @@ 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"
|
|
36
|
-
import {
|
|
37
|
-
attachEffectLedger,
|
|
38
|
-
createRequestLedger,
|
|
39
|
-
DEFAULT_MAX_ENTRIES,
|
|
40
|
-
type EffectLedgerOptions,
|
|
41
|
-
type RequestLedger,
|
|
42
|
-
} from "../ledger.ts"
|
|
23
|
+
import type { RequestLedger } from "../ledger.ts"
|
|
43
24
|
import {
|
|
44
25
|
type CompiledRoutePattern,
|
|
45
26
|
compileRoutePattern,
|
|
@@ -52,12 +33,73 @@ import type {
|
|
|
52
33
|
StandardResult,
|
|
53
34
|
StandardSchemaV1,
|
|
54
35
|
} from "../schema/standard.ts"
|
|
55
|
-
import {
|
|
36
|
+
import { parseContentLength } from "./body.ts"
|
|
37
|
+
import { type ClientIpTrust, resolveClientIp } from "./client-ip.ts"
|
|
56
38
|
import type { Context, Platform, ResponseControls, RouteSchema } from "./context.ts"
|
|
57
|
-
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"
|
|
58
76
|
import { jsonLogger, type Logger } from "./logger.ts"
|
|
59
|
-
import type {
|
|
60
|
-
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"
|
|
61
103
|
import type {
|
|
62
104
|
StandardWebSocket,
|
|
63
105
|
TopicRegistry,
|
|
@@ -66,9 +108,9 @@ import type {
|
|
|
66
108
|
WebSocketUpgradeOutcome,
|
|
67
109
|
} from "./websocket.ts"
|
|
68
110
|
import type { BunWsData } from "./ws-bun.ts"
|
|
69
|
-
import {
|
|
111
|
+
import type { WsRuntime } from "./ws-hook.ts"
|
|
70
112
|
|
|
71
|
-
type MaybePromise<T> = T | Promise<T>
|
|
113
|
+
export type MaybePromise<T> = T | Promise<T>
|
|
72
114
|
|
|
73
115
|
/**
|
|
74
116
|
* Internal request view. A real Web `Request` already satisfies this shape, so Web/edge runtimes pass
|
|
@@ -90,17 +132,6 @@ export interface RequestSource {
|
|
|
90
132
|
readonly request?: Request
|
|
91
133
|
}
|
|
92
134
|
|
|
93
|
-
/** The concrete `Request` for a source — itself when a real `Request` was passed (the Web path), or the
|
|
94
|
-
* lazily-built one (the Node adapter). A real `Request` IS a `RequestSource`, so no wrapper is allocated
|
|
95
|
-
* on the Web hot path. */
|
|
96
|
-
function requestOf(source: RequestSource): Request {
|
|
97
|
-
return source.request ?? (source as unknown as Request)
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
function headerOf(source: RequestSource, name: string): string | null {
|
|
101
|
-
return source.header?.(name) ?? source.headers.get(name)
|
|
102
|
-
}
|
|
103
|
-
|
|
104
135
|
/** The empty context extension. `NonNullable<unknown>` is `{}` without tripping noBannedTypes. */
|
|
105
136
|
type EmptyContext = NonNullable<unknown>
|
|
106
137
|
|
|
@@ -111,42 +142,20 @@ type EmptyContext = NonNullable<unknown>
|
|
|
111
142
|
*/
|
|
112
143
|
type EnvOf<Ctx> = Ctx extends { readonly env: infer E } ? E : unknown
|
|
113
144
|
|
|
114
|
-
/** A handler returns a `Response` (used as-is) or any value (serialized to JSON). */
|
|
115
|
-
type HandlerResult = Response | unknown
|
|
116
145
|
type ContextlessHandler = () => MaybePromise<HandlerResult>
|
|
117
146
|
|
|
118
|
-
const RESPONSE_RESULT = Symbol.for("nifra.response.result")
|
|
119
|
-
const CONTEXT_SET = Symbol("nifra.context.set")
|
|
120
|
-
const CONTEXT_SEARCH = Symbol("nifra.context.search")
|
|
121
147
|
const functionToString = Function.prototype.toString
|
|
122
148
|
const CONTEXTLESS_ARROW = /^(?:async\s*)?\(\s*\)\s*(?::[\s\S]*?)?=>/
|
|
123
149
|
|
|
124
|
-
interface ResponseResult {
|
|
125
|
-
readonly [RESPONSE_RESULT]: true
|
|
126
|
-
toResponse(): Response
|
|
127
|
-
toNodeBody?(): {
|
|
128
|
-
readonly status: number
|
|
129
|
-
readonly headers: Readonly<Record<string, string | readonly string[]>> | undefined
|
|
130
|
-
readonly body: string | Uint8Array
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
function isResponseResult(value: unknown): value is ResponseResult {
|
|
135
|
-
return (
|
|
136
|
-
typeof value === "object" &&
|
|
137
|
-
value !== null &&
|
|
138
|
-
(value as { readonly [RESPONSE_RESULT]?: unknown })[RESPONSE_RESULT] === true &&
|
|
139
|
-
typeof (value as { readonly toResponse?: unknown }).toResponse === "function"
|
|
140
|
-
)
|
|
141
|
-
}
|
|
142
|
-
|
|
143
150
|
/** Internal, path-erased runtime context. The typed `Context<Path, S>` is a structural view of this. */
|
|
144
|
-
interface RawContext {
|
|
151
|
+
export interface RawContext {
|
|
145
152
|
readonly req: Request
|
|
146
153
|
readonly request: Request
|
|
147
154
|
readonly json: (body: unknown, init?: ResponseInit | number) => Response
|
|
148
155
|
readonly text: (body: string, init?: ResponseInit | number) => Response
|
|
149
|
-
|
|
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>
|
|
150
159
|
query: unknown
|
|
151
160
|
readonly cookies: Readonly<Record<string, string>>
|
|
152
161
|
body: unknown
|
|
@@ -156,17 +165,12 @@ interface RawContext {
|
|
|
156
165
|
readonly signal: AbortSignal
|
|
157
166
|
readonly budget: RequestBudget
|
|
158
167
|
readonly env: unknown
|
|
168
|
+
readonly clientIp: string | undefined
|
|
159
169
|
readonly waitUntil: (promise: Promise<unknown>) => void
|
|
160
170
|
readonly boundedBody: (maxBytes?: number) => Promise<Uint8Array>
|
|
161
171
|
readonly boundedJson: <T = unknown>(maxBytes?: number) => Promise<T>
|
|
162
172
|
}
|
|
163
173
|
|
|
164
|
-
/** Off-edge `waitUntil`: run the background work fire-and-forget, never leaking an unhandled
|
|
165
|
-
* rejection. Edge runtimes pass their own (Workers `ctx.waitUntil`) via the platform arg. */
|
|
166
|
-
const fallbackWaitUntil = (promise: Promise<unknown>): void => {
|
|
167
|
-
void promise.catch(() => {})
|
|
168
|
-
}
|
|
169
|
-
|
|
170
174
|
type InternalHandler = (ctx: RawContext) => MaybePromise<HandlerResult>
|
|
171
175
|
|
|
172
176
|
/** Broad shape so the implementation signature is compatible with both typed overloads. */
|
|
@@ -183,27 +187,6 @@ type RawOnRequest = (req: Request, platform?: Platform) => MaybePromise<OnReques
|
|
|
183
187
|
type RawOnResponse = (response: Response, req: Request) => MaybePromise<Response>
|
|
184
188
|
type RawOnResponseFinalized = (outcome: ResponseFinalization, req: Request) => MaybePromise<void>
|
|
185
189
|
|
|
186
|
-
/** Registration-resolved idempotency for a route: the config with its store + defaults pinned. */
|
|
187
|
-
interface ResolvedIdempotency {
|
|
188
|
-
readonly store: IdempotencyStore
|
|
189
|
-
readonly ttlMs: number
|
|
190
|
-
readonly headerName: string
|
|
191
|
-
readonly namespace: NonNullable<RouteSchema["idempotency"]>["namespace"]
|
|
192
|
-
readonly maxResponseBytes: number
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
/** Registration-resolved effect-ledger wiring for one capability-declaring route. */
|
|
196
|
-
interface ResolvedEffectLedger {
|
|
197
|
-
readonly sink: EffectLedgerOptions["sink"]
|
|
198
|
-
readonly maxEntries: number
|
|
199
|
-
readonly chain: boolean
|
|
200
|
-
/** The registered route pattern — what the sealed ledger names (never the concrete URL). */
|
|
201
|
-
readonly method: string
|
|
202
|
-
readonly path: string
|
|
203
|
-
/** The route's declared capability tokens, surfaced on the sealed ledger. */
|
|
204
|
-
readonly declared: readonly string[]
|
|
205
|
-
}
|
|
206
|
-
|
|
207
190
|
type RouteExecutionRunner = <T, R extends Registry, Ctx>(
|
|
208
191
|
runtime: Server<R, Ctx>,
|
|
209
192
|
entry: RouteEntry,
|
|
@@ -340,9 +323,20 @@ interface WsEntry {
|
|
|
340
323
|
readonly handler: WebSocketHandler
|
|
341
324
|
}
|
|
342
325
|
|
|
343
|
-
/** Structural view of the Bun `Server`
|
|
326
|
+
/** Structural view of the Bun `Server` the `fetch` 2nd arg exposes (`upgrade` + the socket peer). */
|
|
344
327
|
interface BunUpgradeServer {
|
|
345
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 }
|
|
346
340
|
}
|
|
347
341
|
|
|
348
342
|
type BunNativeHandler = (request: Request) => MaybePromise<Response>
|
|
@@ -353,12 +347,31 @@ type BunRequestWithParams = Request & { readonly params?: Record<string, string>
|
|
|
353
347
|
const WS_PASS: WebSocketUpgradeOutcome = { kind: "pass" }
|
|
354
348
|
|
|
355
349
|
/** `app.ws()` (and everything downstream of it) needs the runtime `@nifrajs/core/ws` registers. */
|
|
356
|
-
function requireWsRuntime(): WsRuntime {
|
|
357
|
-
const runtime = getWsRuntime()
|
|
350
|
+
function requireWsRuntime(runtime: WsRuntime | undefined): WsRuntime {
|
|
358
351
|
if (runtime === undefined) {
|
|
359
352
|
throw new FrameworkError(
|
|
360
353
|
"WS_RUNTIME_MISSING",
|
|
361
|
-
|
|
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.",
|
|
362
375
|
)
|
|
363
376
|
}
|
|
364
377
|
return runtime
|
|
@@ -381,202 +394,21 @@ export type Handler<
|
|
|
381
394
|
Ctx = EmptyContext,
|
|
382
395
|
> = (ctx: Context<Path, S> & Ctx) => MaybePromise<ResponseOf<S>>
|
|
383
396
|
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
* is untouched.
|
|
400
|
-
*/
|
|
401
|
-
export interface AdmissionController {
|
|
402
|
-
admit(req: Request): AdmissionDecision | Promise<AdmissionDecision>
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
export interface ServerOptions {
|
|
406
|
-
/**
|
|
407
|
-
* Max request body size (bytes), enforced **only when a route declares a body schema** — the cap
|
|
408
|
-
* lives in the schema-validated read path. Default 1_000_000.
|
|
409
|
-
*
|
|
410
|
-
* A route WITHOUT a body schema (raw body, file upload, BYO-validation) that reads `c.req` directly
|
|
411
|
-
* is not auto-bounded — use **`c.boundedBody(maxBytes?)`** / **`c.boundedJson(maxBytes?)`**, which
|
|
412
|
-
* apply this same cap (override per route by passing `maxBytes` — larger for an upload endpoint,
|
|
413
|
-
* smaller to tighten one).
|
|
414
|
-
*/
|
|
415
|
-
readonly maxBodyBytes?: number
|
|
416
|
-
/** Max inbound WebSocket message size (bytes) when `listen()`ing on Bun — frames over this are rejected
|
|
417
|
-
* by the runtime before reaching your handler (so a huge frame can't be JSON-parsed into memory).
|
|
418
|
-
* Default: `maxBodyBytes` (1 MB). */
|
|
419
|
-
readonly wsMaxPayloadBytes?: number
|
|
420
|
-
/** Per-request timeout (ms): a slower request gets a 503 and `ctx.signal` aborts. 0 disables (default). */
|
|
421
|
-
readonly requestTimeoutMs?: number
|
|
422
|
-
/**
|
|
423
|
-
* Admit the public `x-nifra-deadline` header and let it shorten local work. Disabled by default:
|
|
424
|
-
* services that participate in trusted cross-service deadline propagation opt in explicitly,
|
|
425
|
-
* while ordinary/public HTTP routes pay no header-admission tax and ignore hostile client values.
|
|
426
|
-
*/
|
|
427
|
-
readonly acceptInboundDeadlines?: boolean
|
|
428
|
-
/**
|
|
429
|
-
* Maximum time accepted from an inbound `x-nifra-deadline`, in milliseconds. An incoming absolute
|
|
430
|
-
* deadline may shorten this cap (and `requestTimeoutMs`, when configured), never extend it.
|
|
431
|
-
* Default 30_000; applies only when `acceptInboundDeadlines` is enabled and the header is present.
|
|
432
|
-
*/
|
|
433
|
-
readonly maxInboundDeadlineMs?: number
|
|
434
|
-
/** When `listen()`ing, install SIGTERM/SIGINT handlers that gracefully `stop()`. Default false. */
|
|
435
|
-
readonly gracefulSignals?: boolean
|
|
436
|
-
/**
|
|
437
|
-
* Capacity-admission gate (see {@link AdmissionController}). Bounds concurrent in-flight work so a
|
|
438
|
-
* healthy instance sheds load (`429` + `Retry-After`) instead of accepting more than it can finish.
|
|
439
|
-
* Off by default. Enabling it disables the fused native-route fast path (requests route through the
|
|
440
|
-
* shared matched lane where the gate runs); when unset, the request path pays nothing.
|
|
441
|
-
*/
|
|
442
|
-
readonly admission?: AdmissionController
|
|
443
|
-
/**
|
|
444
|
-
* Optional token-only observation hook called by `useCapability(c, id)`. It receives no request,
|
|
445
|
-
* parameters, body, tenant, or values. Routes without capability declarations keep the old hot path.
|
|
446
|
-
*/
|
|
447
|
-
readonly onCapabilityUse?: (event: CapabilityUseEvent) => void
|
|
448
|
-
/**
|
|
449
|
-
* Enable the per-request effect ledger. Each route that declares `schema.capabilities` gets a
|
|
450
|
-
* bounded, token-only ledger; `useCapability(c, id, …)` appends one entry per effect, and the sink
|
|
451
|
-
* receives the sealed ledger when the response settles (only when it recorded entries). Token-only
|
|
452
|
-
* by construction — capability ids, phases, counters, digests; never payloads, values, or the
|
|
453
|
-
* concrete URL (the route *pattern* is recorded). Enabling it moves capability-declaring routes off
|
|
454
|
-
* the fused fast path (they need a per-request context + a settle step); all other routes are
|
|
455
|
-
* unaffected, and when unset the request path pays nothing.
|
|
456
|
-
*/
|
|
457
|
-
readonly effectLedger?: EffectLedgerOptions
|
|
458
|
-
/**
|
|
459
|
-
* Default store backing routes that declare `schema.idempotency` without their own `store`. Defaults
|
|
460
|
-
* to a shared in-process {@link MemoryIdempotencyStore} (created lazily on first idempotent route).
|
|
461
|
-
* Inject a durable store here to make request-scoped dedupe survive restarts app-wide.
|
|
462
|
-
*/
|
|
463
|
-
readonly idempotencyStore?: IdempotencyStore
|
|
464
|
-
/** Structured logger for framework events (redacts secrets). Default: JSON to stderr. */
|
|
465
|
-
readonly logger?: Logger
|
|
466
|
-
/**
|
|
467
|
-
* App-wide fallback fired when a route **without its own `onValidationError`** fails body/query
|
|
468
|
-
* validation. Same contract as the per-route hook (`(issues, ctx, kind) => Response | repaired-value |
|
|
469
|
-
* undefined`): a route's own hook takes precedence, and a route can fall through to the plain `422` by
|
|
470
|
-
* returning `undefined`. Use it for one app-wide error envelope (like tRPC's `errorFormatter` /
|
|
471
|
-
* Fastify's `setErrorHandler`) instead of repeating a formatter per route.
|
|
472
|
-
*/
|
|
473
|
-
readonly onValidationError?: RouteSchema["onValidationError"]
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
/**
|
|
477
|
-
* MCP tool safety hints, surfaced in `tools/list`, that tell an agent how risky a `.tool()` call is — so it
|
|
478
|
-
* can decide whether to auto-invoke or confirm first. All optional; an omitted hint means "unknown". Mirrors
|
|
479
|
-
* the MCP spec's tool `annotations`.
|
|
480
|
-
*/
|
|
481
|
-
export interface ToolAnnotations {
|
|
482
|
-
/** Human-readable display title for the tool, distinct from the machine `name`. */
|
|
483
|
-
readonly title?: string
|
|
484
|
-
/** The tool does not modify its environment (a pure read). */
|
|
485
|
-
readonly readOnlyHint?: boolean
|
|
486
|
-
/** The tool may perform destructive updates — only meaningful when `readOnlyHint` is not `true`. */
|
|
487
|
-
readonly destructiveHint?: boolean
|
|
488
|
-
/** Repeated calls with the same arguments have no additional effect beyond the first. */
|
|
489
|
-
readonly idempotentHint?: boolean
|
|
490
|
-
/** The tool interacts with external entities (an "open world" beyond this server). */
|
|
491
|
-
readonly openWorldHint?: boolean
|
|
492
|
-
}
|
|
493
|
-
|
|
494
|
-
/**
|
|
495
|
-
* A registered route's public descriptor — method, path, and input schemas. The
|
|
496
|
-
* router trie discards the original patterns, so this flat list is what lets tools
|
|
497
|
-
* (e.g. `toOpenAPI`) enumerate routes after registration.
|
|
498
|
-
*/
|
|
499
|
-
export interface RouteDescriptor {
|
|
500
|
-
readonly method: Method
|
|
501
|
-
readonly path: string
|
|
502
|
-
readonly schema: RouteSchema | undefined
|
|
503
|
-
/** Effective enforcement evidence, populated only during reflection/introspection. */
|
|
504
|
-
readonly assurance?: readonly AssuranceEvidence[]
|
|
505
|
-
/** Normalized declared effect tokens, populated only when the route declares any. */
|
|
506
|
-
readonly capabilities?: readonly string[]
|
|
507
|
-
readonly tool?: {
|
|
508
|
-
readonly name: string
|
|
509
|
-
readonly description: string
|
|
510
|
-
readonly annotations?: ToolAnnotations
|
|
511
|
-
}
|
|
512
|
-
}
|
|
513
|
-
|
|
514
|
-
/** A message in an MCP prompt's rendered output (see {@link Server.prompt}). */
|
|
515
|
-
export interface PromptMessage {
|
|
516
|
-
readonly role: "user" | "assistant"
|
|
517
|
-
readonly content: { readonly type: "text"; readonly text: string }
|
|
518
|
-
}
|
|
519
|
-
|
|
520
|
-
/** One declared argument of an MCP prompt, surfaced in `prompts/list`. */
|
|
521
|
-
export interface PromptArgument {
|
|
522
|
-
readonly name: string
|
|
523
|
-
readonly description?: string
|
|
524
|
-
readonly required?: boolean
|
|
525
|
-
}
|
|
526
|
-
|
|
527
|
-
/** An app-declared MCP resource — read-only data an agent can fetch through `nifra mcp`. */
|
|
528
|
-
export interface McpResourceDescriptor {
|
|
529
|
-
readonly uri: string
|
|
530
|
-
readonly name: string
|
|
531
|
-
readonly description?: string
|
|
532
|
-
readonly mimeType?: string
|
|
533
|
-
readonly read: () => MaybePromise<string | { readonly text: string; readonly mimeType?: string }>
|
|
534
|
-
}
|
|
535
|
-
|
|
536
|
-
/** An app-declared MCP prompt — a reusable prompt template an agent can fetch through `nifra mcp`. */
|
|
537
|
-
export interface McpPromptDescriptor {
|
|
538
|
-
readonly name: string
|
|
539
|
-
readonly description: string
|
|
540
|
-
readonly arguments?: readonly PromptArgument[]
|
|
541
|
-
readonly handler: (args: Record<string, string>) => MaybePromise<readonly PromptMessage[]>
|
|
542
|
-
}
|
|
543
|
-
|
|
544
|
-
/**
|
|
545
|
-
* The handle `listen()` returns — the slice of Bun's server nifra holds and exposes.
|
|
546
|
-
* Declared explicitly (rather than `ReturnType<typeof Bun.serve>`) so the public type
|
|
547
|
-
* surface doesn't leak the ambient `Bun` global into consumers' `.d.ts` resolution.
|
|
548
|
-
*/
|
|
549
|
-
export interface RunningServer {
|
|
550
|
-
readonly port: number
|
|
551
|
-
readonly hostname: string
|
|
552
|
-
readonly pendingRequests: number
|
|
553
|
-
stop(closeActiveConnections?: boolean): void
|
|
554
|
-
}
|
|
555
|
-
|
|
556
|
-
/**
|
|
557
|
-
* A bundle of lifecycle hooks applied together via {@link Server.use} — the unit
|
|
558
|
-
* `@nifrajs/middleware` ships (cors, security headers, rate-limit). Every hook is
|
|
559
|
-
* optional and wired to its lifecycle point. Middleware is context-agnostic (sees
|
|
560
|
-
* the base `Context`); `use` does no context-type merging — the full type-merging
|
|
561
|
-
* plugin system is deferred, and `.use` is reserved as its future entry point.
|
|
562
|
-
*/
|
|
563
|
-
export interface Middleware {
|
|
564
|
-
readonly name?: string
|
|
565
|
-
readonly onRequest?: (req: Request, platform?: Platform) => MaybePromise<OnRequestResult>
|
|
566
|
-
readonly around?: <T>(context: Context, next: () => MaybePromise<T>) => MaybePromise<T>
|
|
567
|
-
readonly beforeHandle?: (context: Context) => MaybePromise<unknown>
|
|
568
|
-
readonly afterHandle?: (result: unknown, context: Context) => MaybePromise<unknown>
|
|
569
|
-
readonly onResponse?: (response: Response, req: Request) => MaybePromise<Response>
|
|
570
|
-
readonly onResponseFinalized?: (outcome: ResponseFinalization, req: Request) => MaybePromise<void>
|
|
571
|
-
readonly onError?: (error: unknown, context: Context) => MaybePromise<unknown>
|
|
572
|
-
}
|
|
573
|
-
|
|
574
|
-
/** The terminal response-pipeline outcome observed after every transforming `onResponse` hook. */
|
|
575
|
-
export interface ResponseFinalization {
|
|
576
|
-
/** The final response, or the last response available when a transformation failed. */
|
|
577
|
-
readonly response: Response
|
|
578
|
-
/** A response-hook failure. Terminal observers run fail-open before the error is rethrown. */
|
|
579
|
-
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,
|
|
580
412
|
}
|
|
581
413
|
|
|
582
414
|
// A plugin operates over arbitrary Server shapes; `any` here is the standard framework escape hatch
|
|
@@ -584,119 +416,19 @@ export interface ResponseFinalization {
|
|
|
584
416
|
// biome-ignore lint/suspicious/noExplicitAny: plugins are generic over any Server's Registry/Context
|
|
585
417
|
export type AnyServer = Server<any, any>
|
|
586
418
|
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
export type
|
|
596
|
-
app: In,
|
|
597
|
-
) => Out) & { readonly pluginName?: string }
|
|
598
|
-
|
|
599
|
-
/**
|
|
600
|
-
* A named type-identity plugin built with {@link defineIdentityPlugin}. It returns the same concrete
|
|
601
|
-
* server type it receives, preserving the caller's typed registry and context across `.use()` while
|
|
602
|
-
* still allowing the plugin to register runtime hooks or handlers.
|
|
603
|
-
*/
|
|
604
|
-
export type IdentityPlugin = (<S extends AnyServer>(app: S) => S) & {
|
|
605
|
-
readonly pluginName?: string
|
|
606
|
-
}
|
|
607
|
-
|
|
608
|
-
/**
|
|
609
|
-
* Name + ergonomics for a plugin that **adds typed context** (`derive`/`decorate`). `app.use(myPlugin)`
|
|
610
|
-
* applies it once; a second `use` of the same name is skipped (idempotent), so plugins can depend on each
|
|
611
|
-
* other without double-registering hooks.
|
|
612
|
-
*
|
|
613
|
-
* ```ts
|
|
614
|
-
* export const requestId = definePlugin("requestId", (app) => app.derive(() => ({ requestId: uuid() })))
|
|
615
|
-
* app.use(requestId) // downstream handlers see c.requestId
|
|
616
|
-
* ```
|
|
617
|
-
*
|
|
618
|
-
* FOOTGUN: only use this for a plugin that adds context. For a plugin that **mounts routes/hooks but adds
|
|
619
|
-
* NO context** (an auth router, an audit logger), use {@link defineRouterPlugin} ({@link defineIdentityPlugin}).
|
|
620
|
-
* `definePlugin((app) => app.get(...))` infers `app: Server<any, any>`, so `.use()` returns `Server<any, any>`
|
|
621
|
-
* and your whole typed client silently collapses to `any` — no type error, no runtime error.
|
|
622
|
-
*/
|
|
623
|
-
export function definePlugin<In extends AnyServer, Out extends AnyServer>(
|
|
624
|
-
name: string,
|
|
625
|
-
apply: (app: In) => Out,
|
|
626
|
-
): NifraPlugin<In, Out> {
|
|
627
|
-
return Object.assign(apply, { pluginName: name }) as NifraPlugin<In, Out>
|
|
628
|
-
}
|
|
629
|
-
|
|
630
|
-
/**
|
|
631
|
-
* Define a type-**identity** plugin: it registers routes/hooks as a side effect but returns the app with
|
|
632
|
-
* its `Registry` + `Context` UNCHANGED. Use this (not {@link definePlugin}) for any plugin that doesn't
|
|
633
|
-
* add context types — e.g. one mounting an auth handler. It threads the caller's *concrete* server type
|
|
634
|
-
* through `use`, so routes declared after `app.use(plugin)` keep their types.
|
|
635
|
-
*
|
|
636
|
-
* Why a dedicated helper: `definePlugin((app) => app)` infers `app: Server<any, any>`, so `use` returns
|
|
637
|
-
* `Server<any, any>` and the whole typed client collapses to `any`. The explicit generic return type here
|
|
638
|
-
* (which a plain `Object.assign` can't preserve) is what keeps `use` returning the precise server type.
|
|
639
|
-
*
|
|
640
|
-
* ```ts
|
|
641
|
-
* export const audit = defineIdentityPlugin("audit", (app) => app.onResponse(logResponse))
|
|
642
|
-
* const api = server().get("/a", h).use(audit).get("/b", h) // /a AND /b stay typed
|
|
643
|
-
* ```
|
|
644
|
-
*/
|
|
645
|
-
export function defineIdentityPlugin(
|
|
646
|
-
name: string,
|
|
647
|
-
apply: <S extends AnyServer>(app: S) => S,
|
|
648
|
-
): IdentityPlugin {
|
|
649
|
-
return Object.assign(apply, { pluginName: name }) as IdentityPlugin
|
|
650
|
-
}
|
|
651
|
-
|
|
652
|
-
/**
|
|
653
|
-
* Alias of {@link defineIdentityPlugin} with a name that says what it's FOR: a plugin that **mounts
|
|
654
|
-
* routes/hooks but adds no context type** (an auth router, an audit logger). Use this — not
|
|
655
|
-
* {@link definePlugin} — for any such plugin, or the typed client silently collapses to `any`. The
|
|
656
|
-
* "identity" in {@link defineIdentityPlugin} refers to the type-identity it preserves; `defineRouterPlugin`
|
|
657
|
-
* is the same thing under a clearer name.
|
|
658
|
-
*
|
|
659
|
-
* Mount routes as a **side effect**, then return the app unchanged (registering via `.get`/`.post` would
|
|
660
|
-
* change the type away from the identity `S`; the mounted routes run but aren't in the caller's typed
|
|
661
|
-
* registry — that's the trade that keeps everything else typed):
|
|
662
|
-
*
|
|
663
|
-
* ```ts
|
|
664
|
-
* export const scim = defineRouterPlugin("scim", (app) => {
|
|
665
|
-
* app.get("/scim/v2/Users", listUsers) // side effect: mounted at runtime
|
|
666
|
-
* return app // return S unchanged → routes added after .use(scim) stay typed
|
|
667
|
-
* })
|
|
668
|
-
* const api = server().get("/a", h).use(scim).get("/b", h) // /a AND /b stay typed
|
|
669
|
-
* ```
|
|
670
|
-
*/
|
|
671
|
-
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 }
|
|
672
428
|
|
|
673
429
|
const DEFAULT_MAX_BODY_BYTES = 1_000_000
|
|
674
430
|
const DEFAULT_DRAIN_MS = 10_000
|
|
675
431
|
const DRAIN_POLL_MS = 10
|
|
676
|
-
const TEXT_DECODER = new TextDecoder()
|
|
677
|
-
/**
|
|
678
|
-
* Shared never-aborting signal for `ctx.signal` when no timeout is armed — created lazily and
|
|
679
|
-
* cached. NOT a module-scope `new AbortController()`: edge runtimes (Cloudflare workerd) forbid
|
|
680
|
-
* constructing one in global scope; the first request builds it inside the handler, then it's
|
|
681
|
-
* reused at zero per-request cost.
|
|
682
|
-
*/
|
|
683
|
-
let neverAbortSignal: AbortSignal | undefined
|
|
684
|
-
let unboundedRequestBudget: RequestBudget | undefined
|
|
685
|
-
const getNeverAbortSignal = (): AbortSignal => {
|
|
686
|
-
neverAbortSignal ??= new AbortController().signal
|
|
687
|
-
return neverAbortSignal
|
|
688
|
-
}
|
|
689
|
-
const getUnboundedRequestBudget = (): RequestBudget => {
|
|
690
|
-
unboundedRequestBudget ??= createUnboundedRequestBudget(getNeverAbortSignal())
|
|
691
|
-
return unboundedRequestBudget
|
|
692
|
-
}
|
|
693
|
-
|
|
694
|
-
function jsonError(status: number, error: string, headers?: Record<string, string>): Response {
|
|
695
|
-
return Response.json(
|
|
696
|
-
{ ok: false, error },
|
|
697
|
-
headers !== undefined ? { status, headers } : { status },
|
|
698
|
-
)
|
|
699
|
-
}
|
|
700
432
|
|
|
701
433
|
/** Same-origin check for a WebSocket handshake (CSWSH default): the `Origin`'s host[:port] must equal the
|
|
702
434
|
* request's own host (from `req.url`, which the runtime builds from the `Host` header). Scheme differs
|
|
@@ -717,157 +449,12 @@ function validationError(issues: ReadonlyArray<StandardIssue>): Response {
|
|
|
717
449
|
return Response.json({ ok: false, error: "validation", issues: serialized }, { status: 422 })
|
|
718
450
|
}
|
|
719
451
|
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
// `req.url` from every supported runtime is an absolute, already-normalized URL, so the pathname is
|
|
727
|
-
// the substring after `scheme://host[:port]` up to `?`/`#`. Query-schema routes also need the search
|
|
728
|
-
// string; parsing both in one scanner avoids the old `pathnameOf()` + `searchOf()` double scan.
|
|
729
|
-
export function urlPartsOf(url: string): UrlParts {
|
|
730
|
-
const schemeEnd = url.indexOf("://")
|
|
731
|
-
const start = schemeEnd === -1 ? url.indexOf("/") : url.indexOf("/", schemeEnd + 3)
|
|
732
|
-
if (start === -1) return { pathname: "/", search: "" }
|
|
733
|
-
|
|
734
|
-
let pathEnd = url.length
|
|
735
|
-
let searchStart = -1
|
|
736
|
-
let searchEnd = url.length
|
|
737
|
-
for (let i = start; i < url.length; i++) {
|
|
738
|
-
const c = url.charCodeAt(i)
|
|
739
|
-
if (c === 63 /* ? */ && searchStart === -1) {
|
|
740
|
-
pathEnd = i
|
|
741
|
-
searchStart = i
|
|
742
|
-
} else if (c === 35 /* # */) {
|
|
743
|
-
if (searchStart === -1) pathEnd = i
|
|
744
|
-
searchEnd = i
|
|
745
|
-
break
|
|
746
|
-
}
|
|
747
|
-
}
|
|
748
|
-
|
|
749
|
-
return {
|
|
750
|
-
pathname: url.slice(start, pathEnd),
|
|
751
|
-
search: searchStart === -1 ? "" : url.slice(searchStart, searchEnd),
|
|
752
|
-
}
|
|
753
|
-
}
|
|
754
|
-
|
|
755
|
-
// Extract the pathname WITHOUT a full WHATWG `new URL(req.url)` parse. Kept as a public-ish helper
|
|
756
|
-
// for tests and callers that only need the path; the request hot path uses `urlPartsOf()` once.
|
|
757
|
-
export function pathnameOf(url: string): string {
|
|
758
|
-
return urlPartsOf(url).pathname
|
|
759
|
-
}
|
|
760
|
-
|
|
761
|
-
// The query string ("?a=1", or "" when absent) — for lazily building `c.query` only when read.
|
|
762
|
-
// The fragment (after the first `#`) bounds the search: a `?` that appears only inside the fragment
|
|
763
|
-
// is NOT a query (matches WHATWG). Fragments never reach the server in `req.url`, so this is purely
|
|
764
|
-
// for provable equivalence with `new URL(req.url).search`.
|
|
765
|
-
export function searchOf(url: string): string {
|
|
766
|
-
return urlPartsOf(url).search
|
|
767
|
-
}
|
|
768
|
-
|
|
769
|
-
/** A query value: a single occurrence is a string; a repeated key promotes to a string[] so an
|
|
770
|
-
* array query schema (`t.array(t.string())`) can validate `?tag=a&tag=b` — last-wins silently
|
|
771
|
-
* dropped values before (audit 2026-06). Single-occurrence keys stay plain strings, so existing
|
|
772
|
-
* `t.string()` schemas are untouched; a repeated key against a string schema now FAILS validation
|
|
773
|
-
* (an explicit 400 beats silently picking one). */
|
|
774
|
-
export type QueryValue = string | string[]
|
|
775
|
-
|
|
776
|
-
/** Accumulate into a NULL-PROTOTYPE record (every call site below creates one): with no inherited
|
|
777
|
-
* `constructor`/`toString`/`__proto__` accessors, a hostile key is just an own data key — the
|
|
778
|
-
* promotion logic can't collide with `Object.prototype` members, and `__proto__` needs no special
|
|
779
|
-
* case (direct assignment on a null-proto object creates an own property). */
|
|
780
|
-
function setQueryValue(out: Record<string, QueryValue>, key: string, value: string): void {
|
|
781
|
-
const existing = out[key]
|
|
782
|
-
if (existing === undefined) {
|
|
783
|
-
out[key] = value
|
|
784
|
-
} else if (typeof existing === "string") {
|
|
785
|
-
out[key] = [existing, value]
|
|
786
|
-
} else {
|
|
787
|
-
existing.push(value)
|
|
788
|
-
}
|
|
789
|
-
}
|
|
790
|
-
|
|
791
|
-
function queryObjectFallback(search: string): Record<string, QueryValue> {
|
|
792
|
-
// Manual iteration instead of Object.fromEntries: repeated keys must promote to arrays
|
|
793
|
-
// (fromEntries is last-wins), and __proto__ needs the same own-property guard.
|
|
794
|
-
const out: Record<string, QueryValue> = Object.create(null) as Record<string, QueryValue>
|
|
795
|
-
for (const [key, value] of new URLSearchParams(search)) {
|
|
796
|
-
setQueryValue(out, key, value)
|
|
797
|
-
}
|
|
798
|
-
return out
|
|
799
|
-
}
|
|
800
|
-
|
|
801
|
-
/**
|
|
802
|
-
* Build the plain object passed to query schemas. Plain ASCII-ish queries avoid
|
|
803
|
-
* `URLSearchParams` + iterator allocations; encoded queries fall back to the Web API so `+`,
|
|
804
|
-
* percent-decoding, malformed escapes, and empty-key behavior stay exact. Repeated keys promote
|
|
805
|
-
* to `string[]` on BOTH paths (see {@link setQueryValue}).
|
|
806
|
-
*/
|
|
807
|
-
// Shared empty result for no-query requests: frozen + null-prototype (same shape contract as the
|
|
808
|
-
// populated path), allocated once instead of per request.
|
|
809
|
-
const EMPTY_QUERY = Object.freeze(Object.create(null)) as Record<string, QueryValue>
|
|
810
|
-
|
|
811
|
-
export function queryObjectOf(search: string): Record<string, QueryValue> {
|
|
812
|
-
const start = search.charCodeAt(0) === 63 /* ? */ ? 1 : 0
|
|
813
|
-
if (start >= search.length) return EMPTY_QUERY
|
|
814
|
-
|
|
815
|
-
for (let i = start; i < search.length; i++) {
|
|
816
|
-
const c = search.charCodeAt(i)
|
|
817
|
-
if (c === 37 /* % */ || c === 43 /* + */) return queryObjectFallback(search)
|
|
818
|
-
}
|
|
819
|
-
|
|
820
|
-
const out: Record<string, QueryValue> = Object.create(null) as Record<string, QueryValue>
|
|
821
|
-
let pos = start
|
|
822
|
-
while (pos <= search.length) {
|
|
823
|
-
const amp = search.indexOf("&", pos)
|
|
824
|
-
const end = amp === -1 ? search.length : amp
|
|
825
|
-
if (end > pos) {
|
|
826
|
-
const eq = search.indexOf("=", pos)
|
|
827
|
-
const split = eq !== -1 && eq < end ? eq : end
|
|
828
|
-
const key = search.slice(pos, split)
|
|
829
|
-
const value = split === end ? "" : search.slice(split + 1, end)
|
|
830
|
-
setQueryValue(out, key, value)
|
|
831
|
-
}
|
|
832
|
-
if (amp === -1) break
|
|
833
|
-
pos = amp + 1
|
|
834
|
-
}
|
|
835
|
-
return out
|
|
836
|
-
}
|
|
837
|
-
|
|
838
|
-
/** `application/x-www-form-urlencoded`, with or without a charset suffix. */
|
|
839
|
-
function isUrlEncodedForm(contentType: string): boolean {
|
|
840
|
-
return (
|
|
841
|
-
contentType === "application/x-www-form-urlencoded" ||
|
|
842
|
-
contentType.startsWith("application/x-www-form-urlencoded;")
|
|
843
|
-
)
|
|
844
|
-
}
|
|
845
|
-
|
|
846
|
-
const FORM_DECODER = new TextDecoder()
|
|
847
|
-
|
|
848
|
-
/**
|
|
849
|
-
* Read an HTML-form body (urlencoded) into the plain object a body schema validates — same byte
|
|
850
|
-
* cap as the JSON path (a lying/absent Content-Length can't force an oversized buffer), same
|
|
851
|
-
* repeated-key → `string[]` promotion as query parsing, same `__proto__` guard.
|
|
852
|
-
*/
|
|
853
|
-
async function readBoundedForm(
|
|
854
|
-
req: RequestSource,
|
|
855
|
-
maxBytes: number,
|
|
856
|
-
): Promise<Record<string, QueryValue> | Response> {
|
|
857
|
-
const read = await readBoundedBytes(req, maxBytes)
|
|
858
|
-
if (!read.ok) {
|
|
859
|
-
return read.status === 413
|
|
860
|
-
? jsonError(413, "payload_too_large")
|
|
861
|
-
: jsonError(400, "invalid_content_length")
|
|
862
|
-
}
|
|
863
|
-
const out: Record<string, QueryValue> = Object.create(null) as Record<string, QueryValue>
|
|
864
|
-
// URLSearchParams owns the format's quirks (`+` as space, percent-decoding, empty keys);
|
|
865
|
-
// it never throws on junk input, so no try/catch is needed here.
|
|
866
|
-
for (const [key, value] of new URLSearchParams(FORM_DECODER.decode(read.bytes))) {
|
|
867
|
-
setQueryValue(out, key, value)
|
|
868
|
-
}
|
|
869
|
-
return out
|
|
870
|
-
}
|
|
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 }
|
|
871
458
|
|
|
872
459
|
function hasReplacementParam(params: Record<string, string>): boolean {
|
|
873
460
|
for (const key in params) {
|
|
@@ -878,48 +465,12 @@ function hasReplacementParam(params: Record<string, string>): boolean {
|
|
|
878
465
|
|
|
879
466
|
/** `ctx.set` carrying the lazy backings (`_headers`, `_cookies`) so `toResponse` can skip allocating
|
|
880
467
|
* anything when no handler touched `c.set.*`. Server-internal. */
|
|
881
|
-
type CtxSet = ResponseControls & {
|
|
468
|
+
export type CtxSet = ResponseControls & {
|
|
882
469
|
_headers?: Record<string, string>
|
|
883
470
|
/** Accumulated `Set-Cookie` values — a list, since a `Record` would collapse multiple cookies. */
|
|
884
471
|
_cookies?: string[]
|
|
885
472
|
}
|
|
886
473
|
|
|
887
|
-
class LazyResponseControls implements CtxSet {
|
|
888
|
-
status?: number
|
|
889
|
-
_headers?: Record<string, string>
|
|
890
|
-
_cookies?: string[]
|
|
891
|
-
|
|
892
|
-
get headers(): Record<string, string> {
|
|
893
|
-
this._headers ??= {}
|
|
894
|
-
return this._headers
|
|
895
|
-
}
|
|
896
|
-
|
|
897
|
-
cookie(name: string, value: string, options?: CookieOptions): void {
|
|
898
|
-
// Secure-by-default: HttpOnly + Secure + SameSite=Lax + Path=/, overridable per call.
|
|
899
|
-
const merged: CookieOptions = {
|
|
900
|
-
httpOnly: true,
|
|
901
|
-
secure: true,
|
|
902
|
-
sameSite: "lax",
|
|
903
|
-
path: "/",
|
|
904
|
-
...options,
|
|
905
|
-
}
|
|
906
|
-
this._cookies ??= []
|
|
907
|
-
this._cookies.push(serializeCookie(name, value, merged))
|
|
908
|
-
}
|
|
909
|
-
|
|
910
|
-
deleteCookie(name: string, options?: Pick<CookieOptions, "path" | "domain">): void {
|
|
911
|
-
// Expire immediately; default Path=/, and match the original path/domain or the browser keeps it.
|
|
912
|
-
this._cookies ??= []
|
|
913
|
-
this._cookies.push(
|
|
914
|
-
serializeCookie(name, "", { path: "/", ...options, maxAge: 0, expires: EPOCH }),
|
|
915
|
-
)
|
|
916
|
-
}
|
|
917
|
-
}
|
|
918
|
-
|
|
919
|
-
// Finalization only reads `status`, `_headers`, and `_cookies`. The user-visible mutator methods
|
|
920
|
-
// live on `LazyResponseControls`, created by the `c.set` getter only when user code touches it.
|
|
921
|
-
const EMPTY_RESPONSE_CONTROLS = Object.freeze({}) as CtxSet
|
|
922
|
-
|
|
923
474
|
function responseSet(ctx: RawContext): CtxSet {
|
|
924
475
|
return ctx[CONTEXT_SET]() ?? EMPTY_RESPONSE_CONTROLS
|
|
925
476
|
}
|
|
@@ -933,407 +484,9 @@ function isContextlessNoArgArrow(handler: (context: never) => unknown): boolean
|
|
|
933
484
|
}
|
|
934
485
|
}
|
|
935
486
|
|
|
936
|
-
/** Coerce `c.json`/`c.text`'s second arg — a status number (the common case) or a full `ResponseInit`. */
|
|
937
|
-
function statusInit(init?: ResponseInit | number): ResponseInit | undefined {
|
|
938
|
-
return typeof init === "number" ? { status: init } : init
|
|
939
|
-
}
|
|
940
|
-
|
|
941
|
-
class RequestContext implements RawContext {
|
|
942
|
-
// `declare` keeps TypeScript's class-field emit from first writing `undefined` to every slot; the
|
|
943
|
-
// constructor initializes only the eager request state, while lazy fields remain absent until used.
|
|
944
|
-
declare readonly params: Record<string, string>
|
|
945
|
-
declare body: unknown
|
|
946
|
-
private declare searchValue: string | undefined
|
|
947
|
-
private declare signalValue: AbortSignal | undefined
|
|
948
|
-
private declare budgetValue: RequestBudget | undefined
|
|
949
|
-
private declare platformValue: Platform | undefined
|
|
950
|
-
|
|
951
|
-
private declare setValue: CtxSet | undefined
|
|
952
|
-
private declare queryValue: unknown
|
|
953
|
-
private declare queryReady: boolean
|
|
954
|
-
private declare cookiesValue: Readonly<Record<string, string>> | undefined
|
|
955
|
-
private declare readonly source: RequestSource
|
|
956
|
-
private declare readonly maxBodyBytes: number
|
|
957
|
-
|
|
958
|
-
constructor(source: RequestSource, params: Record<string, string>, maxBodyBytes: number)
|
|
959
|
-
constructor(
|
|
960
|
-
source: RequestSource,
|
|
961
|
-
params: Record<string, string>,
|
|
962
|
-
search: string | undefined,
|
|
963
|
-
signal: AbortSignal,
|
|
964
|
-
budget: RequestBudget,
|
|
965
|
-
platform: Platform | undefined,
|
|
966
|
-
maxBodyBytes: number,
|
|
967
|
-
)
|
|
968
|
-
constructor(
|
|
969
|
-
source: RequestSource,
|
|
970
|
-
params: Record<string, string>,
|
|
971
|
-
searchOrMaxBodyBytes: string | number | undefined,
|
|
972
|
-
signal?: AbortSignal,
|
|
973
|
-
budget?: RequestBudget,
|
|
974
|
-
platform?: Platform,
|
|
975
|
-
maxBodyBytes?: number,
|
|
976
|
-
) {
|
|
977
|
-
this.source = source
|
|
978
|
-
this.params = params
|
|
979
|
-
if (typeof searchOrMaxBodyBytes === "number") {
|
|
980
|
-
this.maxBodyBytes = searchOrMaxBodyBytes
|
|
981
|
-
return
|
|
982
|
-
}
|
|
983
|
-
if (searchOrMaxBodyBytes !== undefined) this.searchValue = searchOrMaxBodyBytes
|
|
984
|
-
this.signalValue = signal
|
|
985
|
-
this.budgetValue = budget
|
|
986
|
-
if (platform !== undefined) this.platformValue = platform
|
|
987
|
-
this.maxBodyBytes = maxBodyBytes as number
|
|
988
|
-
}
|
|
989
|
-
|
|
990
|
-
static native(
|
|
991
|
-
source: RequestSource,
|
|
992
|
-
params: Record<string, string>,
|
|
993
|
-
maxBodyBytes: number,
|
|
994
|
-
): RequestContext {
|
|
995
|
-
return new RequestContext(source, params, maxBodyBytes)
|
|
996
|
-
}
|
|
997
|
-
|
|
998
|
-
[CONTEXT_SET](): CtxSet | undefined {
|
|
999
|
-
return this.setValue
|
|
1000
|
-
}
|
|
1001
|
-
|
|
1002
|
-
get [CONTEXT_SEARCH](): string {
|
|
1003
|
-
this.searchValue ??= searchOf(this.source.url)
|
|
1004
|
-
return this.searchValue
|
|
1005
|
-
}
|
|
1006
|
-
|
|
1007
|
-
get set(): CtxSet {
|
|
1008
|
-
this.setValue ??= new LazyResponseControls()
|
|
1009
|
-
return this.setValue
|
|
1010
|
-
}
|
|
1011
|
-
|
|
1012
|
-
get signal(): AbortSignal {
|
|
1013
|
-
return this.signalValue ?? getNeverAbortSignal()
|
|
1014
|
-
}
|
|
1015
|
-
|
|
1016
|
-
get budget(): RequestBudget {
|
|
1017
|
-
return this.budgetValue ?? getUnboundedRequestBudget()
|
|
1018
|
-
}
|
|
1019
|
-
|
|
1020
|
-
get env(): unknown {
|
|
1021
|
-
return this.platformValue?.env
|
|
1022
|
-
}
|
|
1023
|
-
|
|
1024
|
-
get waitUntil(): (promise: Promise<unknown>) => void {
|
|
1025
|
-
return this.platformValue?.waitUntil ?? fallbackWaitUntil
|
|
1026
|
-
}
|
|
1027
|
-
|
|
1028
|
-
get req(): Request {
|
|
1029
|
-
return requestOf(this.source)
|
|
1030
|
-
}
|
|
1031
|
-
|
|
1032
|
-
get request(): Request {
|
|
1033
|
-
return requestOf(this.source)
|
|
1034
|
-
}
|
|
1035
|
-
|
|
1036
|
-
json(body: unknown, init?: ResponseInit | number): Response {
|
|
1037
|
-
return Response.json(body, statusInit(init))
|
|
1038
|
-
}
|
|
1039
|
-
|
|
1040
|
-
text(body: string, init?: ResponseInit | number): Response {
|
|
1041
|
-
const i = statusInit(init)
|
|
1042
|
-
// Default to text/plain; if the caller passes their own headers, they own the content-type.
|
|
1043
|
-
if (i?.headers !== undefined) return new Response(body, i)
|
|
1044
|
-
return new Response(body, { ...i, headers: { "content-type": "text/plain; charset=utf-8" } })
|
|
1045
|
-
}
|
|
1046
|
-
|
|
1047
|
-
get query(): unknown {
|
|
1048
|
-
if (!this.queryReady) {
|
|
1049
|
-
this.queryValue = new URLSearchParams(this[CONTEXT_SEARCH])
|
|
1050
|
-
this.queryReady = true
|
|
1051
|
-
}
|
|
1052
|
-
return this.queryValue
|
|
1053
|
-
}
|
|
1054
|
-
|
|
1055
|
-
set query(v: unknown) {
|
|
1056
|
-
this.queryValue = v
|
|
1057
|
-
this.queryReady = true
|
|
1058
|
-
}
|
|
1059
|
-
|
|
1060
|
-
get cookies(): Readonly<Record<string, string>> {
|
|
1061
|
-
this.cookiesValue ??= parseCookies(headerOf(this.source, "cookie"))
|
|
1062
|
-
return this.cookiesValue
|
|
1063
|
-
}
|
|
1064
|
-
|
|
1065
|
-
boundedBody(maxBytes?: number): Promise<Uint8Array> {
|
|
1066
|
-
return readBoundedBodyOrThrow(this.source, this.maxBodyBytes, maxBytes)
|
|
1067
|
-
}
|
|
1068
|
-
|
|
1069
|
-
boundedJson<T = unknown>(maxBytes?: number): Promise<T> {
|
|
1070
|
-
return readBoundedJsonBodyOrThrow<T>(this.source, this.maxBodyBytes, maxBytes)
|
|
1071
|
-
}
|
|
1072
|
-
}
|
|
1073
|
-
|
|
1074
|
-
/** Backs `c.boundedBody`: bounded byte read that throws a flat 413/400 `Response` (caught by
|
|
1075
|
-
* `runLifecycle` as control flow, like `throw redirect(...)`), so a handler can't ignore the cap.
|
|
1076
|
-
* The byte-cap itself lives in `./body.ts` (shared with the schema path and `verifyWebhook`). */
|
|
1077
|
-
async function readBoundedBodyOrThrow(
|
|
1078
|
-
req: RequestSource,
|
|
1079
|
-
maxBodyBytes: number,
|
|
1080
|
-
maxBytes?: number,
|
|
1081
|
-
): Promise<Uint8Array> {
|
|
1082
|
-
const r = await readBoundedBytes(req, maxBytes ?? maxBodyBytes)
|
|
1083
|
-
if (r.ok) return r.bytes
|
|
1084
|
-
throw r.status === 413
|
|
1085
|
-
? jsonError(413, "payload_too_large")
|
|
1086
|
-
: jsonError(400, "invalid_content_length")
|
|
1087
|
-
}
|
|
1088
|
-
|
|
1089
|
-
/** Backs `c.boundedJson`: `readBoundedBodyOrThrow` + `JSON.parse`, throwing a flat 400 on bad JSON. */
|
|
1090
|
-
async function readBoundedJsonBodyOrThrow<T>(
|
|
1091
|
-
req: RequestSource,
|
|
1092
|
-
maxBodyBytes: number,
|
|
1093
|
-
maxBytes?: number,
|
|
1094
|
-
): Promise<T> {
|
|
1095
|
-
const parsed = await readBoundedJsonSource(req, maxBytes ?? maxBodyBytes)
|
|
1096
|
-
if (parsed instanceof Response) throw parsed
|
|
1097
|
-
return parsed as T
|
|
1098
|
-
}
|
|
1099
|
-
|
|
1100
|
-
/**
|
|
1101
|
-
* Read a JSON body with the same byte cap used by schema validation and `c.boundedJson`.
|
|
1102
|
-
* Non-chunked, framed requests with an in-cap `Content-Length` use the runtime-native `req.json()`;
|
|
1103
|
-
* chunked or length-less requests fall back to the streaming byte-cap guard.
|
|
1104
|
-
*/
|
|
1105
|
-
async function readBoundedJsonSource(
|
|
1106
|
-
req: RequestSource,
|
|
1107
|
-
maxBytes: number,
|
|
1108
|
-
): Promise<unknown | Response> {
|
|
1109
|
-
const declared = headerOf(req, "content-length")
|
|
1110
|
-
if (declared !== null) {
|
|
1111
|
-
// A present Content-Length must be a non-negative integer (HTTP grammar: `1*DIGIT`). A
|
|
1112
|
-
// non-numeric / negative / fractional / exponential value (`Number()` would happily accept
|
|
1113
|
-
// "abc"→NaN, "-5", "1.5", "1e3", "0x10") is malformed → 400, rather than silently falling
|
|
1114
|
-
// through to the streaming guard — which is an UPPER-bound cap only, so a lying SMALLER length
|
|
1115
|
-
// would otherwise be read in full. Real HTTP servers only hand us a valid framed length; this
|
|
1116
|
-
// hardens hand-built Requests (tests, the in-process client) and crafted input.
|
|
1117
|
-
const length = parseContentLength(declared)
|
|
1118
|
-
if (length === undefined) return jsonError(400, "invalid_content_length")
|
|
1119
|
-
if (length > maxBytes) return jsonError(413, "payload_too_large")
|
|
1120
|
-
const chunked = headerOf(req, "transfer-encoding") !== null
|
|
1121
|
-
if (!chunked) {
|
|
1122
|
-
try {
|
|
1123
|
-
return await req.json()
|
|
1124
|
-
} catch {
|
|
1125
|
-
return jsonError(400, "invalid_json")
|
|
1126
|
-
}
|
|
1127
|
-
}
|
|
1128
|
-
}
|
|
1129
|
-
const body = req.body
|
|
1130
|
-
if (body === null) return jsonError(400, "invalid_json")
|
|
1131
|
-
const drained = await drainCapped(body, maxBytes)
|
|
1132
|
-
if (!drained.ok) return jsonError(413, "payload_too_large")
|
|
1133
|
-
try {
|
|
1134
|
-
return JSON.parse(TEXT_DECODER.decode(drained.bytes))
|
|
1135
|
-
} catch {
|
|
1136
|
-
return jsonError(400, "invalid_json")
|
|
1137
|
-
}
|
|
1138
|
-
}
|
|
1139
|
-
|
|
1140
|
-
/** A fixed past instant for cookie deletion (`Expires`). A literal epoch — deterministic, unlike an
|
|
1141
|
-
* argless `new Date()`. */
|
|
1142
|
-
const EPOCH = new Date(0)
|
|
1143
|
-
|
|
1144
|
-
/** Build the response headers init. The common path (no `c.set`) returns `undefined` so `Response`
|
|
1145
|
-
* gets no `headers` at all. Cookies force a `Headers` object — multiple `Set-Cookie`s can't live in a
|
|
1146
|
-
* `Record<string,string>` (the 2nd would overwrite the 1st), so they're `append`ed individually. */
|
|
1147
|
-
function headersInit(set: CtxSet): Record<string, string> | Headers | undefined {
|
|
1148
|
-
const cookies = set._cookies
|
|
1149
|
-
if (cookies === undefined || cookies.length === 0) return set._headers
|
|
1150
|
-
const headers = new Headers(set._headers)
|
|
1151
|
-
for (const cookie of cookies) headers.append("set-cookie", cookie)
|
|
1152
|
-
return headers
|
|
1153
|
-
}
|
|
1154
|
-
|
|
1155
|
-
// Keep the fast JSON respond path byte-identical to `Response.json` without probing it at module
|
|
1156
|
-
// scope: workerd forbids `Response.json()` during startup. A shared `Headers` is safe to reuse
|
|
1157
|
-
// across responses: the Response constructor copies `init.headers` into its own list.
|
|
1158
|
-
const JSON_CT_HEADERS = new Headers({
|
|
1159
|
-
"content-type": "application/json;charset=utf-8",
|
|
1160
|
-
})
|
|
1161
|
-
const JSON_INIT_200: ResponseInit = { status: 200, headers: JSON_CT_HEADERS }
|
|
1162
|
-
|
|
1163
|
-
/** Fused-lane respond when `c.set` is untouched. Bun 1.3's native `Response.json` now beats the
|
|
1164
|
-
* older hand-inlined stringify + Response construction on this lane while preserving the exact
|
|
1165
|
-
* body/content-type contract; keep the generic fallback for non-JSON values. */
|
|
1166
|
-
function fusedRespondNoSet(result: unknown): Response {
|
|
1167
|
-
if (
|
|
1168
|
-
result !== undefined &&
|
|
1169
|
-
!(result instanceof Response) &&
|
|
1170
|
-
typeof result === "object" &&
|
|
1171
|
-
result !== null &&
|
|
1172
|
-
!isResponseResult(result)
|
|
1173
|
-
) {
|
|
1174
|
-
return Response.json(result)
|
|
1175
|
-
}
|
|
1176
|
-
return toResponse(result as HandlerResult, EMPTY_RESPONSE_CONTROLS)
|
|
1177
|
-
}
|
|
1178
|
-
|
|
1179
|
-
/** Fused-lane respond with a context: read `c.set` once; untouched (the common case) → the fast
|
|
1180
|
-
* JSON respond; touched → the generic `toResponse` with those controls (statuses, headers, cookies). */
|
|
1181
|
-
function fusedRespond(result: unknown, ctx: RawContext): Response {
|
|
1182
|
-
const set = ctx[CONTEXT_SET]()
|
|
1183
|
-
if (set === undefined) return fusedRespondNoSet(result)
|
|
1184
|
-
return toResponse(result as HandlerResult, set)
|
|
1185
|
-
}
|
|
1186
|
-
|
|
1187
|
-
function toResponse(result: HandlerResult, set: CtxSet): Response {
|
|
1188
|
-
if (isResponseResult(result)) {
|
|
1189
|
-
return appendCookiesToResponse(result.toResponse(), set)
|
|
1190
|
-
}
|
|
1191
|
-
if (result instanceof Response) {
|
|
1192
|
-
return appendCookiesToResponse(result, set)
|
|
1193
|
-
}
|
|
1194
|
-
const headers = headersInit(set)
|
|
1195
|
-
const status = set.status ?? (result === undefined ? 204 : 200)
|
|
1196
|
-
if (headers === undefined && result !== undefined) {
|
|
1197
|
-
// Fast respond (profiled ≈ −50 ns/req on every plain-JSON return): `JSON.stringify` + a
|
|
1198
|
-
// prebuilt init beats `Response.json`'s internal init handling. Output is byte-identical —
|
|
1199
|
-
// same body bytes, same probed content-type. `undefined` from stringify (a function/symbol
|
|
1200
|
-
// result) delegates to Response.json so its TypeError contract stays the single source.
|
|
1201
|
-
const body = JSON.stringify(result) as string | undefined
|
|
1202
|
-
if (body !== undefined) {
|
|
1203
|
-
return new Response(
|
|
1204
|
-
body,
|
|
1205
|
-
status === 200 ? JSON_INIT_200 : { status, headers: JSON_CT_HEADERS },
|
|
1206
|
-
)
|
|
1207
|
-
}
|
|
1208
|
-
}
|
|
1209
|
-
const init: ResponseInit = headers === undefined ? { status } : { status, headers }
|
|
1210
|
-
return result === undefined ? new Response(null, init) : Response.json(result, init)
|
|
1211
|
-
}
|
|
1212
|
-
|
|
1213
|
-
function appendCookiesToResponse(response: Response, set: CtxSet): Response {
|
|
1214
|
-
// A handler may queue cookies (`c.set.cookie` — e.g. a session cookie) AND return its own Response
|
|
1215
|
-
// (e.g. `redirect("/")` after login). Cookies accumulate additively, so append them to the
|
|
1216
|
-
// returned Response — otherwise the canonical set-session-then-redirect pattern would silently drop
|
|
1217
|
-
// the cookie. (Other `c.set` fields stay the returned Response's own concern.)
|
|
1218
|
-
const cookies = set._cookies
|
|
1219
|
-
if (cookies !== undefined && cookies.length > 0) {
|
|
1220
|
-
for (const cookie of cookies) response.headers.append("set-cookie", cookie)
|
|
1221
|
-
}
|
|
1222
|
-
return response
|
|
1223
|
-
}
|
|
1224
|
-
|
|
1225
|
-
/**
|
|
1226
|
-
* What {@link Server.resolveNode} returns: either a plain-data render the `@nifrajs/node` adapter writes
|
|
1227
|
-
* to the socket directly (`kind: "json"` — status + headers + cookies + a pre-stringified body, **no**
|
|
1228
|
-
* undici `Response` built or drained), a marked buffered response body (`kind: "body"` — e.g.
|
|
1229
|
-
* @nifrajs/web's non-deferred SSR HTML), or a `Response` (`kind: "response"`) for everything else
|
|
1230
|
-
* (redirects, 404/405/errors, unmarked or streaming bodies). Internal to the nifra↔node bridge.
|
|
1231
|
-
*/
|
|
1232
|
-
export type NodeServeOutcome =
|
|
1233
|
-
| { readonly kind: "response"; readonly response: Response }
|
|
1234
|
-
| {
|
|
1235
|
-
readonly kind: "json"
|
|
1236
|
-
readonly status: number
|
|
1237
|
-
/** `c.set.headers` backing, or `undefined` when the handler never set a header. */
|
|
1238
|
-
readonly headers: Readonly<Record<string, string>> | undefined
|
|
1239
|
-
/** Queued `Set-Cookie` lines, or `undefined`; the adapter emits one header line each. */
|
|
1240
|
-
readonly cookies: readonly string[] | undefined
|
|
1241
|
-
/** The JSON body already stringified, or `null` for an empty (204) response. */
|
|
1242
|
-
readonly body: string | null
|
|
1243
|
-
}
|
|
1244
|
-
| {
|
|
1245
|
-
readonly kind: "body"
|
|
1246
|
-
readonly status: number
|
|
1247
|
-
readonly headers: Readonly<Record<string, string | readonly string[]>> | undefined
|
|
1248
|
-
readonly body: string | Uint8Array
|
|
1249
|
-
}
|
|
1250
|
-
|
|
1251
|
-
/**
|
|
1252
|
-
* `finalize` for the node-direct path — mirror of {@link toResponse} that skips the `Response` build:
|
|
1253
|
-
* a plain value becomes pre-stringified JSON primitives (the adapter `JSON.stringify`s once, here, not
|
|
1254
|
-
* via `Response.json` + a body drain); a handler-returned `Response` is wrapped as-is, with queued
|
|
1255
|
-
* cookies appended exactly as `toResponse` does (so the set-cookie-then-`redirect()` pattern still
|
|
1256
|
-
* works on Node).
|
|
1257
|
-
*/
|
|
1258
|
-
function toNodeOutcome(result: HandlerResult, set: CtxSet): NodeServeOutcome {
|
|
1259
|
-
if (isResponseResult(result)) {
|
|
1260
|
-
const body = result.toNodeBody?.()
|
|
1261
|
-
if (body !== undefined) {
|
|
1262
|
-
return {
|
|
1263
|
-
kind: "body",
|
|
1264
|
-
status: body.status,
|
|
1265
|
-
headers: appendCookiesToNodeHeaders(body.headers, set._cookies),
|
|
1266
|
-
body: body.body,
|
|
1267
|
-
}
|
|
1268
|
-
}
|
|
1269
|
-
return nodeOutcomeFromResponse(appendCookiesToResponse(result.toResponse(), set))
|
|
1270
|
-
}
|
|
1271
|
-
if (result instanceof Response) {
|
|
1272
|
-
return nodeOutcomeFromResponse(appendCookiesToResponse(result, set))
|
|
1273
|
-
}
|
|
1274
|
-
const status = set.status ?? (result === undefined ? 204 : 200)
|
|
1275
|
-
return {
|
|
1276
|
-
kind: "json",
|
|
1277
|
-
status,
|
|
1278
|
-
headers: set._headers,
|
|
1279
|
-
cookies: set._cookies,
|
|
1280
|
-
body: result === undefined ? null : JSON.stringify(result),
|
|
1281
|
-
}
|
|
1282
|
-
}
|
|
1283
|
-
|
|
1284
487
|
// Stable module-level finalizers so `fetch`/`resolveNode` allocate no per-request closures.
|
|
1285
488
|
const IDENTITY_RESPONSE = (response: Response): Response => response
|
|
1286
489
|
const RESPONSE_TIMEOUT = (): Response => jsonError(503, "request_timeout")
|
|
1287
|
-
const NODE_RESPONSE_BODY = Symbol.for("nifra.response.body")
|
|
1288
|
-
const NODE_FROM_RESPONSE = (response: Response): NodeServeOutcome =>
|
|
1289
|
-
nodeOutcomeFromResponse(response)
|
|
1290
|
-
const NODE_TIMEOUT = (): NodeServeOutcome => ({
|
|
1291
|
-
kind: "response",
|
|
1292
|
-
response: jsonError(503, "request_timeout"),
|
|
1293
|
-
})
|
|
1294
|
-
|
|
1295
|
-
function nodeOutcomeFromResponse(response: Response): NodeServeOutcome {
|
|
1296
|
-
const body = nodeResponseBody(response)
|
|
1297
|
-
return body === undefined
|
|
1298
|
-
? { kind: "response", response }
|
|
1299
|
-
: { kind: "body", status: response.status, headers: responseHeadersForNode(response), body }
|
|
1300
|
-
}
|
|
1301
|
-
|
|
1302
|
-
function nodeResponseBody(response: Response): string | Uint8Array | undefined {
|
|
1303
|
-
if (response.bodyUsed) return undefined
|
|
1304
|
-
const body = (response as { readonly [NODE_RESPONSE_BODY]?: unknown })[NODE_RESPONSE_BODY]
|
|
1305
|
-
return typeof body === "string" || body instanceof Uint8Array ? body : undefined
|
|
1306
|
-
}
|
|
1307
|
-
|
|
1308
|
-
function responseHeadersForNode(
|
|
1309
|
-
response: Response,
|
|
1310
|
-
): Readonly<Record<string, string | readonly string[]>> | undefined {
|
|
1311
|
-
let headers: Record<string, string | readonly string[]> | undefined
|
|
1312
|
-
response.headers.forEach((value, key) => {
|
|
1313
|
-
headers ??= {}
|
|
1314
|
-
headers[key] = value
|
|
1315
|
-
})
|
|
1316
|
-
const setCookies = response.headers.getSetCookie?.()
|
|
1317
|
-
if (setCookies !== undefined && setCookies.length > 0) {
|
|
1318
|
-
headers ??= {}
|
|
1319
|
-
headers["set-cookie"] = setCookies
|
|
1320
|
-
}
|
|
1321
|
-
return headers
|
|
1322
|
-
}
|
|
1323
|
-
|
|
1324
|
-
function appendCookiesToNodeHeaders(
|
|
1325
|
-
headers: Readonly<Record<string, string | readonly string[]>> | undefined,
|
|
1326
|
-
cookies: readonly string[] | undefined,
|
|
1327
|
-
): Readonly<Record<string, string | readonly string[]>> | undefined {
|
|
1328
|
-
if (cookies === undefined || cookies.length === 0) return headers
|
|
1329
|
-
const out: Record<string, string | readonly string[]> =
|
|
1330
|
-
headers === undefined ? {} : { ...headers }
|
|
1331
|
-
const existing = out["set-cookie"]
|
|
1332
|
-
const setCookies =
|
|
1333
|
-
existing === undefined ? [] : typeof existing === "string" ? [existing] : [...existing]
|
|
1334
|
-
out["set-cookie"] = [...setCookies, ...cookies]
|
|
1335
|
-
return out
|
|
1336
|
-
}
|
|
1337
490
|
|
|
1338
491
|
/**
|
|
1339
492
|
* The inline server. Routes are chainable and fully type-inferred. `derive`/
|
|
@@ -1355,6 +508,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1355
508
|
private readonly maxBodyBytes: number
|
|
1356
509
|
private readonly wsMaxPayloadBytes: number
|
|
1357
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
|
|
1358
513
|
private readonly acceptInboundDeadlines: boolean
|
|
1359
514
|
private readonly maxInboundDeadlineMs: number
|
|
1360
515
|
private readonly deadlineAdmissionOptions: DeadlineAdmissionOptions
|
|
@@ -1362,13 +517,20 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1362
517
|
/** Capacity-admission gate; `undefined` = off (the request path pays nothing). */
|
|
1363
518
|
private readonly capacityGate: AdmissionController | undefined
|
|
1364
519
|
private readonly onCapabilityUse: ((event: CapabilityUseEvent) => void) | undefined
|
|
1365
|
-
/**
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
private
|
|
1371
|
-
|
|
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
|
|
1372
534
|
private readonly logger: Logger
|
|
1373
535
|
/** App-wide validation-error fallback; a route's own `schema.onValidationError` takes precedence. */
|
|
1374
536
|
private readonly defaultOnValidationError?: RouteSchema["onValidationError"]
|
|
@@ -1401,6 +563,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1401
563
|
this.maxBodyBytes = options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES
|
|
1402
564
|
this.wsMaxPayloadBytes = options.wsMaxPayloadBytes ?? this.maxBodyBytes
|
|
1403
565
|
this.requestTimeoutMs = options.requestTimeoutMs ?? 0
|
|
566
|
+
this.clientIpTrust = options.clientIp
|
|
1404
567
|
this.acceptInboundDeadlines = options.acceptInboundDeadlines ?? false
|
|
1405
568
|
this.maxInboundDeadlineMs = options.maxInboundDeadlineMs ?? 30_000
|
|
1406
569
|
this.deadlineAdmissionOptions = Object.freeze({
|
|
@@ -1416,25 +579,12 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1416
579
|
this.gracefulSignals = options.gracefulSignals ?? false
|
|
1417
580
|
this.capacityGate = options.admission
|
|
1418
581
|
this.onCapabilityUse = options.onCapabilityUse
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
if (!Number.isSafeInteger(maxEntries) || maxEntries < 1) {
|
|
1426
|
-
throw new RangeError("effectLedger.maxEntries must be a positive safe integer")
|
|
1427
|
-
}
|
|
1428
|
-
this.effectLedger = Object.freeze({
|
|
1429
|
-
sink: ledger.sink,
|
|
1430
|
-
maxEntries,
|
|
1431
|
-
chain: ledger.chain ?? false,
|
|
1432
|
-
})
|
|
1433
|
-
} else {
|
|
1434
|
-
this.effectLedger = undefined
|
|
1435
|
-
}
|
|
1436
|
-
this.configuredIdempotencyStore = options.idempotencyStore
|
|
1437
|
-
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
|
|
1438
588
|
this.logger = options.logger ?? jsonLogger()
|
|
1439
589
|
this.defaultOnValidationError = options.onValidationError
|
|
1440
590
|
this.bunServer = undefined
|
|
@@ -1629,7 +779,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1629
779
|
* JSON-serialized into the SSE `data:` field. The typed client sees the marker and grows a
|
|
1630
780
|
* `.subscribe(onEvent)` for the route with the same payload type — end-to-end typed streaming.
|
|
1631
781
|
*
|
|
1632
|
-
*
|
|
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() }) },
|
|
1633
784
|
* async (c, stream) => {
|
|
1634
785
|
* stream.send({ id: 1, title: "hello" }) // typed
|
|
1635
786
|
* await waitForDisconnect(stream.signal)
|
|
@@ -1652,7 +803,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1652
803
|
init?: SSEInit,
|
|
1653
804
|
): Server<AddRoute<R, "GET", Path, RouteInfoFor<Path, S, Response>>, Ctx> {
|
|
1654
805
|
const handler = (context: Context<Path, S> & Ctx): Response =>
|
|
1655
|
-
|
|
806
|
+
requireSseRuntime(this.sseRuntime).response(context, (stream) => run(context, stream), init)
|
|
1656
807
|
return this.route("GET", path, schema, handler as unknown as ErasedHandler) as Server<
|
|
1657
808
|
AddRoute<R, "GET", Path, RouteInfoFor<Path, S, Response>>,
|
|
1658
809
|
Ctx
|
|
@@ -1727,6 +878,19 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1727
878
|
return this.route("DELETE", path, schemaOrHandler, handler)
|
|
1728
879
|
}
|
|
1729
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
|
+
*/
|
|
1730
894
|
tool<
|
|
1731
895
|
Name extends string,
|
|
1732
896
|
S extends {
|
|
@@ -1768,24 +932,17 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1768
932
|
},
|
|
1769
933
|
handler: (input: unknown, ctx: Context & Ctx) => unknown,
|
|
1770
934
|
): Server<Registry, Ctx> {
|
|
1771
|
-
const
|
|
1772
|
-
|
|
1773
|
-
config
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
// the overload above, so bridge with a single cast rather than `any`.
|
|
1778
|
-
const run = (c: Context & Ctx): unknown => handler(c.body, c)
|
|
1779
|
-
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)
|
|
1780
941
|
// Tag the just-registered descriptor as an MCP tool. `tool` is readonly on RouteDescriptor (an
|
|
1781
942
|
// introspection field), so write it through a narrow mutable view — not `any`.
|
|
1782
943
|
const lastRoute = this.catalog.lastDescriptor()
|
|
1783
944
|
if (lastRoute) {
|
|
1784
|
-
;(lastRoute as { tool?: RouteDescriptor["tool"] }).tool =
|
|
1785
|
-
name,
|
|
1786
|
-
description: config.description,
|
|
1787
|
-
...(config.annotations !== undefined ? { annotations: config.annotations } : {}),
|
|
1788
|
-
}
|
|
945
|
+
;(lastRoute as { tool?: RouteDescriptor["tool"] }).tool = plan.descriptor
|
|
1789
946
|
}
|
|
1790
947
|
return this as unknown as Server<Registry, Ctx>
|
|
1791
948
|
}
|
|
@@ -1802,13 +959,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1802
959
|
read: McpResourceDescriptor["read"],
|
|
1803
960
|
): Server<R, Ctx> {
|
|
1804
961
|
this.assertConfigurable("resource()")
|
|
1805
|
-
this.mcpResourceList.push(
|
|
1806
|
-
uri,
|
|
1807
|
-
name: config.name,
|
|
1808
|
-
...(config.description !== undefined ? { description: config.description } : {}),
|
|
1809
|
-
...(config.mimeType !== undefined ? { mimeType: config.mimeType } : {}),
|
|
1810
|
-
read,
|
|
1811
|
-
})
|
|
962
|
+
this.mcpResourceList.push(requireMcpRuntime(this.mcpRuntime).resource(uri, config, read))
|
|
1812
963
|
return this
|
|
1813
964
|
}
|
|
1814
965
|
|
|
@@ -1822,12 +973,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1822
973
|
handler: McpPromptDescriptor["handler"],
|
|
1823
974
|
): Server<R, Ctx> {
|
|
1824
975
|
this.assertConfigurable("prompt()")
|
|
1825
|
-
this.mcpPromptList.push(
|
|
1826
|
-
name,
|
|
1827
|
-
description: config.description,
|
|
1828
|
-
...(config.arguments !== undefined ? { arguments: config.arguments } : {}),
|
|
1829
|
-
handler,
|
|
1830
|
-
})
|
|
976
|
+
this.mcpPromptList.push(requireMcpRuntime(this.mcpRuntime).prompt(name, config, handler))
|
|
1831
977
|
return this
|
|
1832
978
|
}
|
|
1833
979
|
|
|
@@ -1848,16 +994,29 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1848
994
|
* adapter (`listen()`, `@nifrajs/node`, `@nifrajs/deno`, `toFetchHandler`) — not by bare `app.fetch`, which
|
|
1849
995
|
* has no socket (a WS path through `app.fetch` is a normal HTTP response).
|
|
1850
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
|
+
*
|
|
1851
1003
|
* app.ws("/chat", { open: (ws) => ws.send("hi"), message: (ws, data) => ws.send(data) })
|
|
1852
1004
|
*/
|
|
1853
|
-
ws<
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
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> {
|
|
1857
1016
|
this.assertConfigurable("ws()")
|
|
1858
1017
|
// Boot-time guard: the WS runtime is a subpath (`@nifrajs/core/ws`) so no-WebSocket apps don't
|
|
1859
1018
|
// bundle it. Registration is the loud, early failure point — never the first connection.
|
|
1860
|
-
const runtime = requireWsRuntime()
|
|
1019
|
+
const runtime = requireWsRuntime(this.wsRuntime)
|
|
1861
1020
|
this.topics ??= runtime.createTopics()
|
|
1862
1021
|
// A `messageSchema` wraps `message` with validation once, here — every adapter then dispatches
|
|
1863
1022
|
// already-validated, typed messages (Bun/Deno/Node/Workers) with no per-adapter code.
|
|
@@ -1865,7 +1024,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1865
1024
|
handler: runtime.wrapHandler(handler as WebSocketHandler),
|
|
1866
1025
|
})
|
|
1867
1026
|
this.wsRouteCount += 1
|
|
1868
|
-
return this
|
|
1027
|
+
return this as never
|
|
1869
1028
|
}
|
|
1870
1029
|
|
|
1871
1030
|
/**
|
|
@@ -1966,16 +1125,26 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1966
1125
|
const hasDecorations = Reflect.ownKeys(routeDecorations).length > 0
|
|
1967
1126
|
// An idempotency route runs a dedupe lane that must buffer the body and capture the response, so it
|
|
1968
1127
|
// never takes the fused/native fast path — force it onto the portable matched lane (which routes
|
|
1969
|
-
// through `fetchMatched`, where the dedupe wrapper lives).
|
|
1970
|
-
|
|
1971
|
-
//
|
|
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
|
|
1972
1139
|
// context to carry the ledger and a settle step to seal + sink it, so it too leaves the
|
|
1973
1140
|
// fused/contextless fast path. Resolved per route, at registration — like the capability guard.
|
|
1974
|
-
const ledgered: ResolvedEffectLedger | undefined =
|
|
1975
|
-
capabilities
|
|
1976
|
-
|
|
1977
|
-
|
|
1141
|
+
const ledgered: ResolvedEffectLedger | undefined = this.effectLedgerRuntime?.resolve(
|
|
1142
|
+
capabilities,
|
|
1143
|
+
method,
|
|
1144
|
+
path,
|
|
1145
|
+
)
|
|
1978
1146
|
const bare =
|
|
1147
|
+
schema?.params === undefined &&
|
|
1979
1148
|
schema?.body === undefined &&
|
|
1980
1149
|
schema?.query === undefined &&
|
|
1981
1150
|
idempotent === undefined &&
|
|
@@ -1997,6 +1166,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1997
1166
|
? "bare"
|
|
1998
1167
|
: schema?.body !== undefined &&
|
|
1999
1168
|
schema.query === undefined &&
|
|
1169
|
+
schema.params === undefined &&
|
|
2000
1170
|
this.derives.length === 0 &&
|
|
2001
1171
|
this.beforeHandleHooks.length === 0 &&
|
|
2002
1172
|
this.afterHandleHooks.length === 0 &&
|
|
@@ -2004,6 +1174,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2004
1174
|
? "body"
|
|
2005
1175
|
: schema?.body === undefined &&
|
|
2006
1176
|
schema?.query !== undefined &&
|
|
1177
|
+
schema.params === undefined &&
|
|
2007
1178
|
this.derives.length === 0 &&
|
|
2008
1179
|
this.beforeHandleHooks.length === 0 &&
|
|
2009
1180
|
this.afterHandleHooks.length === 0 &&
|
|
@@ -2038,8 +1209,20 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2038
1209
|
path,
|
|
2039
1210
|
schema,
|
|
2040
1211
|
...(capabilities.length > 0 ? { capabilities } : {}),
|
|
1212
|
+
...(schema?.family === true ? { family: true } : {}),
|
|
2041
1213
|
}
|
|
2042
1214
|
const routeAssurance: AssuranceDeclaration[] = [...this.activeAssurance, ...handlerAssurance]
|
|
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" }))
|
|
1225
|
+
}
|
|
2043
1226
|
if (schema?.body !== undefined) {
|
|
2044
1227
|
routeAssurance.push(
|
|
2045
1228
|
Object.freeze({
|
|
@@ -2156,23 +1339,21 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2156
1339
|
runtime.maxBodyBytes,
|
|
2157
1340
|
)
|
|
2158
1341
|
let ledger: RequestLedger | undefined
|
|
2159
|
-
|
|
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) {
|
|
2160
1345
|
const resolved = entry.ledgered as ResolvedEffectLedger
|
|
2161
|
-
ledger =
|
|
2162
|
-
|
|
2163
|
-
path: resolved.path,
|
|
2164
|
-
declared: resolved.declared,
|
|
2165
|
-
maxEntries: resolved.maxEntries,
|
|
2166
|
-
chain: resolved.chain,
|
|
2167
|
-
})
|
|
2168
|
-
attachEffectLedger(ctx, ledger)
|
|
1346
|
+
ledger = ledgerRuntime.create(resolved)
|
|
1347
|
+
ledgerRuntime.attach(ctx, ledger)
|
|
2169
1348
|
}
|
|
2170
1349
|
let outcome = execute(runtime, entry, source, ctx, finalize, wrapResponse)
|
|
2171
|
-
if (ledger !== undefined) {
|
|
1350
|
+
if (ledger !== undefined && ledgerRuntime !== undefined) {
|
|
2172
1351
|
const active = ledger
|
|
2173
1352
|
const resolved = entry.ledgered as ResolvedEffectLedger
|
|
2174
1353
|
outcome = (outcome instanceof Promise ? outcome : Promise.resolve(outcome)).then((value) =>
|
|
2175
|
-
|
|
1354
|
+
ledgerRuntime.settle(active, resolved, value, (fields) =>
|
|
1355
|
+
runtime.logger.error("effect ledger sink failed", fields),
|
|
1356
|
+
),
|
|
2176
1357
|
)
|
|
2177
1358
|
}
|
|
2178
1359
|
return outcome
|
|
@@ -2180,82 +1361,58 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2180
1361
|
return Object.freeze({ run, fusedWeb })
|
|
2181
1362
|
}
|
|
2182
1363
|
|
|
2183
|
-
/**
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
|
|
2209
|
-
|
|
2210
|
-
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
|
|
2216
|
-
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
|
|
2221
|
-
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
|
-
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
`invalid idempotency namespace ${JSON.stringify(namespace)}`,
|
|
2230
|
-
)
|
|
2231
|
-
}
|
|
2232
|
-
if (authenticated && typeof namespace !== "function") {
|
|
2233
|
-
throw new RouteConfigError(
|
|
2234
|
-
"INVALID_IDEMPOTENCY",
|
|
2235
|
-
"authenticated idempotency routes require a principal namespace resolver",
|
|
2236
|
-
)
|
|
2237
|
-
}
|
|
2238
|
-
const maxResponseBytes = config.maxResponseBytes ?? this.maxBodyBytes
|
|
2239
|
-
if (!Number.isSafeInteger(maxResponseBytes) || maxResponseBytes < 1) {
|
|
2240
|
-
throw new RouteConfigError(
|
|
2241
|
-
"INVALID_IDEMPOTENCY",
|
|
2242
|
-
"idempotency maxResponseBytes must be a positive integer",
|
|
2243
|
-
)
|
|
2244
|
-
}
|
|
2245
|
-
return Object.freeze({
|
|
2246
|
-
store,
|
|
2247
|
-
ttlMs,
|
|
2248
|
-
headerName,
|
|
2249
|
-
namespace,
|
|
2250
|
-
maxResponseBytes,
|
|
2251
|
-
})
|
|
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
|
|
2252
1410
|
}
|
|
2253
1411
|
|
|
2254
|
-
/**
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
this.
|
|
2258
|
-
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
|
|
2259
1416
|
}
|
|
2260
1417
|
|
|
2261
1418
|
/**
|
|
@@ -2287,6 +1444,17 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2287
1444
|
)
|
|
2288
1445
|
}
|
|
2289
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
|
|
2290
1458
|
this.onRequestHooks.push(...source.onRequestHooks)
|
|
2291
1459
|
this.onResponseHooks.push(...source.onResponseHooks)
|
|
2292
1460
|
this.onResponseFinalizedHooks.push(...source.onResponseFinalizedHooks)
|
|
@@ -2470,6 +1638,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2470
1638
|
resolveNodeSource(
|
|
2471
1639
|
source: RequestSource,
|
|
2472
1640
|
platform?: Platform<EnvOf<Ctx>>,
|
|
1641
|
+
suppliedRuntime?: NodeOutcomeRuntime,
|
|
2473
1642
|
): MaybePromise<NodeServeOutcome> {
|
|
2474
1643
|
// onResponse hooks transform a Response, and the capacity gate wraps the Web response path — both
|
|
2475
1644
|
// force the Web path here (the gated `fetchSource` admits/sheds/releases); wrap its result.
|
|
@@ -2486,12 +1655,19 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2486
1655
|
// May resolve **synchronously** for a compiled bare route + sync handler - the `@nifrajs/node`
|
|
2487
1656
|
// adapter `await`s the result, so it transparently handles either; the sync case allocates no promise
|
|
2488
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
|
+
}
|
|
2489
1665
|
return this.dispatch<NodeServeOutcome>(
|
|
2490
1666
|
source,
|
|
2491
1667
|
platform,
|
|
2492
|
-
|
|
2493
|
-
|
|
2494
|
-
|
|
1668
|
+
runtime.toOutcome,
|
|
1669
|
+
runtime.fromResponse,
|
|
1670
|
+
runtime.timeout,
|
|
2495
1671
|
false,
|
|
2496
1672
|
)
|
|
2497
1673
|
}
|
|
@@ -2518,8 +1694,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2518
1694
|
const params = match.params === EMPTY_PARAMS ? match.params : decodeRouteParams(match.params)
|
|
2519
1695
|
if (params === null) return { kind: "reject", response: jsonError(400, "malformed_path") }
|
|
2520
1696
|
const handler = match.payload.handler
|
|
2521
|
-
// Non-null: wsRouteCount > 0 ⇒ ws() ran ⇒ the runtime
|
|
1697
|
+
// Non-null: wsRouteCount > 0 ⇒ ws() ran ⇒ `.use(websocket())` installed the runtime + registry.
|
|
2522
1698
|
const pubsub = this.topics as TopicRegistry
|
|
1699
|
+
const attach = (this.wsRuntime as WsRuntime).attach
|
|
2523
1700
|
// CSWSH guard, before any per-connection work or the user's upgrade(): reject a disallowed
|
|
2524
1701
|
// Origin with 403. Browsers don't CORS-protect WS handshakes but do send cookies, so this
|
|
2525
1702
|
// blocks cross-site authenticated sockets when the route opts in via `allowedOrigins`.
|
|
@@ -2538,7 +1715,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2538
1715
|
return { kind: "reject", response: jsonError(403, "forbidden_origin") }
|
|
2539
1716
|
}
|
|
2540
1717
|
if (handler.upgrade === undefined) {
|
|
2541
|
-
return { kind: "upgrade", handler, data: undefined, pubsub }
|
|
1718
|
+
return { kind: "upgrade", handler, data: undefined, pubsub, attach }
|
|
2542
1719
|
}
|
|
2543
1720
|
const upgradeSignal = getNeverAbortSignal()
|
|
2544
1721
|
const ctx = new RequestContext(
|
|
@@ -2553,7 +1730,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2553
1730
|
const settle = (value: unknown): WebSocketUpgradeOutcome =>
|
|
2554
1731
|
value instanceof Response
|
|
2555
1732
|
? { kind: "reject", response: value }
|
|
2556
|
-
: { kind: "upgrade", handler, data: value, pubsub }
|
|
1733
|
+
: { kind: "upgrade", handler, data: value, pubsub, attach }
|
|
2557
1734
|
try {
|
|
2558
1735
|
const result = handler.upgrade(ctx as unknown as WebSocketContext<EnvOf<Ctx>>)
|
|
2559
1736
|
return result instanceof Promise
|
|
@@ -2575,7 +1752,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2575
1752
|
server: BunUpgradeServer,
|
|
2576
1753
|
): MaybePromise<Response | undefined> {
|
|
2577
1754
|
const handle = (o: WebSocketUpgradeOutcome): MaybePromise<Response | undefined> => {
|
|
2578
|
-
if (o.kind === "pass")
|
|
1755
|
+
if (o.kind === "pass")
|
|
1756
|
+
return this.fetch(req, bunPeerPlatform(server, req) as Platform<EnvOf<Ctx>>)
|
|
2579
1757
|
if (o.kind === "reject") return o.response
|
|
2580
1758
|
return server.upgrade(req, { data: { handler: o.handler, data: o.data } })
|
|
2581
1759
|
? undefined
|
|
@@ -2592,6 +1770,16 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2592
1770
|
* produces the 503. The Web `fetch` and `resolveNode` are thin callers over this one routing +
|
|
2593
1771
|
* context + lifecycle implementation — no duplication across the trust boundary.
|
|
2594
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
|
+
|
|
2595
1783
|
private dispatch<T>(
|
|
2596
1784
|
source: RequestSource,
|
|
2597
1785
|
platform: Platform | undefined,
|
|
@@ -2602,12 +1790,17 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2602
1790
|
// `Response` (`T = Response` there by construction; the node path always passes false).
|
|
2603
1791
|
webFast: boolean,
|
|
2604
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)
|
|
2605
1798
|
// onRequest hooks may be async, so a hooked app takes the async path; with no hooks (the common
|
|
2606
1799
|
// case) routing stays synchronous, letting a bare route resolve with no lifecycle promise at all.
|
|
2607
1800
|
if (this.onRequestHooks.length === 0) {
|
|
2608
|
-
return this.routeAndRun(source,
|
|
1801
|
+
return this.routeAndRun(source, resolved, finalize, wrapResponse, onTimeout, webFast)
|
|
2609
1802
|
}
|
|
2610
|
-
return this.runWithOnRequest(source,
|
|
1803
|
+
return this.runWithOnRequest(source, resolved, finalize, wrapResponse, onTimeout, webFast)
|
|
2611
1804
|
}
|
|
2612
1805
|
|
|
2613
1806
|
/**
|
|
@@ -2752,8 +1945,22 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2752
1945
|
): MaybePromise<T> {
|
|
2753
1946
|
// An idempotency route runs its dedupe lane first; on a fresh key it delegates to the normal lanes
|
|
2754
1947
|
// (with the body buffered). All non-idempotent routes skip straight to the lanes — no added cost.
|
|
2755
|
-
|
|
2756
|
-
|
|
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
|
+
)
|
|
2757
1964
|
}
|
|
2758
1965
|
return this.runMatchedLanes(
|
|
2759
1966
|
source,
|
|
@@ -2768,130 +1975,6 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2768
1975
|
)
|
|
2769
1976
|
}
|
|
2770
1977
|
|
|
2771
|
-
/**
|
|
2772
|
-
* Idempotency dedupe lane. Reads the `Idempotency-Key`, fingerprints the request, and consults the
|
|
2773
|
-
* store: a fresh key runs the handler (via {@link runMatchedLanes} with the body buffered) and stores
|
|
2774
|
-
* the response; a repeat key replays it without re-running; a key reused with a different body or an
|
|
2775
|
-
* in-flight duplicate is rejected. Every branch materializes a `Response`, then adapts to `T` via the
|
|
2776
|
-
* caller's `wrapResponse`, so it works on the Web fetch path and the Node-native outcome path alike.
|
|
2777
|
-
*/
|
|
2778
|
-
private async runIdempotent<T>(
|
|
2779
|
-
source: RequestSource,
|
|
2780
|
-
platform: Platform | undefined,
|
|
2781
|
-
entry: RouteEntry,
|
|
2782
|
-
params: Record<string, string>,
|
|
2783
|
-
search: string | undefined,
|
|
2784
|
-
wrapResponse: (response: Response) => T,
|
|
2785
|
-
): Promise<T> {
|
|
2786
|
-
const config = entry.idempotent as ResolvedIdempotency
|
|
2787
|
-
const req = requestOf(source)
|
|
2788
|
-
const key = req.headers.get(config.headerName)
|
|
2789
|
-
if (key === null || !validIdempotencyKey(key)) {
|
|
2790
|
-
return wrapResponse(jsonError(400, "idempotency_key_required"))
|
|
2791
|
-
}
|
|
2792
|
-
const namespace =
|
|
2793
|
-
typeof config.namespace === "function"
|
|
2794
|
-
? await config.namespace(req.clone(), platform)
|
|
2795
|
-
: config.namespace
|
|
2796
|
-
if (!validIdempotencyNamespace(namespace)) {
|
|
2797
|
-
return wrapResponse(jsonError(500, "idempotency_namespace_invalid"))
|
|
2798
|
-
}
|
|
2799
|
-
const read = await readBoundedBytes(req, this.maxBodyBytes)
|
|
2800
|
-
if (!read.ok) {
|
|
2801
|
-
return wrapResponse(
|
|
2802
|
-
jsonError(read.status, read.status === 413 ? "payload_too_large" : "bad_request"),
|
|
2803
|
-
)
|
|
2804
|
-
}
|
|
2805
|
-
const url = urlPartsOf(req.url)
|
|
2806
|
-
const canonicalBody = canonicalizeIdempotencyBody(read.bytes, req.headers.get("content-type"))
|
|
2807
|
-
const fingerprint = await computeIdempotencyFingerprint(
|
|
2808
|
-
req.method,
|
|
2809
|
-
`${url.pathname}${url.search}`,
|
|
2810
|
-
canonicalBody,
|
|
2811
|
-
req.headers.get("content-type") ?? "",
|
|
2812
|
-
)
|
|
2813
|
-
|
|
2814
|
-
const begin = await config.store.begin({ namespace, key, fingerprint, ttlMs: config.ttlMs })
|
|
2815
|
-
if (begin.state === "replay") {
|
|
2816
|
-
return wrapResponse(responseFromStored(begin.response, { maxBytes: config.maxResponseBytes }))
|
|
2817
|
-
}
|
|
2818
|
-
if (begin.state === "mismatch") return wrapResponse(jsonError(409, "idempotency_key_reused"))
|
|
2819
|
-
if (begin.state === "in-flight") {
|
|
2820
|
-
return wrapResponse(jsonError(409, "idempotency_in_progress", { "Retry-After": "1" }))
|
|
2821
|
-
}
|
|
2822
|
-
if (begin.state === "capacity") {
|
|
2823
|
-
return wrapResponse(jsonError(503, "idempotency_store_capacity", { "Retry-After": "1" }))
|
|
2824
|
-
}
|
|
2825
|
-
const reservation = begin.reservation
|
|
2826
|
-
|
|
2827
|
-
// Fresh key: re-expose the buffered body as a Request so the normal lanes can read + validate it,
|
|
2828
|
-
// then run those lanes to a concrete Response.
|
|
2829
|
-
const bufferedInit: RequestInit = {
|
|
2830
|
-
method: req.method,
|
|
2831
|
-
headers: req.headers,
|
|
2832
|
-
signal: req.signal,
|
|
2833
|
-
}
|
|
2834
|
-
if (read.bytes.byteLength > 0)
|
|
2835
|
-
bufferedInit.body = read.bytes as NonNullable<RequestInit["body"]>
|
|
2836
|
-
const buffered: RequestSource = new Request(req.url, bufferedInit)
|
|
2837
|
-
let response: Response
|
|
2838
|
-
try {
|
|
2839
|
-
response = await this.runMatchedLanes(
|
|
2840
|
-
buffered,
|
|
2841
|
-
platform,
|
|
2842
|
-
entry,
|
|
2843
|
-
params,
|
|
2844
|
-
search,
|
|
2845
|
-
toResponse,
|
|
2846
|
-
IDENTITY_RESPONSE,
|
|
2847
|
-
RESPONSE_TIMEOUT,
|
|
2848
|
-
false,
|
|
2849
|
-
)
|
|
2850
|
-
} catch (err) {
|
|
2851
|
-
// The lanes never throw (errors resolve to a 500 Response), but stay fail-safe: release the
|
|
2852
|
-
// reservation so a retry isn't wedged, then re-throw.
|
|
2853
|
-
await config.store.abandon({ namespace, key, reservation })
|
|
2854
|
-
throw err
|
|
2855
|
-
}
|
|
2856
|
-
// Cache only successful responses; release the key on any error so the client can retry.
|
|
2857
|
-
if (response.status >= 200 && response.status < 300) {
|
|
2858
|
-
let storedResponse: Awaited<ReturnType<typeof serializeResponse>>
|
|
2859
|
-
try {
|
|
2860
|
-
storedResponse = await serializeResponse(response, { maxBytes: config.maxResponseBytes })
|
|
2861
|
-
} catch (error) {
|
|
2862
|
-
if (!(error instanceof IdempotencyResponseTooLargeError)) throw error
|
|
2863
|
-
// The effect may already have happened, so never abandon and permit a duplicate execution.
|
|
2864
|
-
// Commit a small terminal response under the winning key and return that same response now.
|
|
2865
|
-
response = jsonError(507, "idempotency_response_too_large")
|
|
2866
|
-
try {
|
|
2867
|
-
storedResponse = await serializeResponse(response, {
|
|
2868
|
-
maxBytes: config.maxResponseBytes,
|
|
2869
|
-
})
|
|
2870
|
-
} catch (terminalError) {
|
|
2871
|
-
if (!(terminalError instanceof IdempotencyResponseTooLargeError)) throw terminalError
|
|
2872
|
-
// Even an intentionally tiny bound must remain truthful. An empty 507 preserves terminal
|
|
2873
|
-
// status + replay safety without silently storing more bytes than the route permits.
|
|
2874
|
-
response = new Response(null, { status: 507 })
|
|
2875
|
-
storedResponse = await serializeResponse(response, {
|
|
2876
|
-
maxBytes: config.maxResponseBytes,
|
|
2877
|
-
})
|
|
2878
|
-
}
|
|
2879
|
-
}
|
|
2880
|
-
const completed = await config.store.complete({
|
|
2881
|
-
namespace,
|
|
2882
|
-
key,
|
|
2883
|
-
reservation,
|
|
2884
|
-
response: storedResponse,
|
|
2885
|
-
})
|
|
2886
|
-
if (!completed) {
|
|
2887
|
-
return wrapResponse(jsonError(503, "idempotency_reservation_lost", { "Retry-After": "1" }))
|
|
2888
|
-
}
|
|
2889
|
-
} else {
|
|
2890
|
-
await config.store.abandon({ namespace, key, reservation })
|
|
2891
|
-
}
|
|
2892
|
-
return wrapResponse(response)
|
|
2893
|
-
}
|
|
2894
|
-
|
|
2895
1978
|
/** Supply request-specific deadline state to the route's precompiled execution plan. */
|
|
2896
1979
|
private runMatchedLanes<T>(
|
|
2897
1980
|
source: RequestSource,
|
|
@@ -2979,32 +2062,10 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2979
2062
|
return outcome
|
|
2980
2063
|
}
|
|
2981
2064
|
|
|
2982
|
-
/**
|
|
2983
|
-
|
|
2984
|
-
|
|
2985
|
-
|
|
2986
|
-
* the owned effect transaction, so a sink outage must not turn a successful effect into a retryable
|
|
2987
|
-
* 500 that can duplicate it.
|
|
2988
|
-
*/
|
|
2989
|
-
private async settleEffectLedger<T>(
|
|
2990
|
-
ledger: RequestLedger,
|
|
2991
|
-
resolved: ResolvedEffectLedger,
|
|
2992
|
-
value: T,
|
|
2993
|
-
): Promise<T> {
|
|
2994
|
-
const sealed = await ledger.seal()
|
|
2995
|
-
if (sealed.entries.length === 0) return value
|
|
2996
|
-
try {
|
|
2997
|
-
await resolved.sink(sealed)
|
|
2998
|
-
} catch (err) {
|
|
2999
|
-
// Token-only logging: the route pattern and the failure name — never entry payloads (there are
|
|
3000
|
-
// none) and never the request.
|
|
3001
|
-
this.logger.error("effect ledger sink failed", {
|
|
3002
|
-
method: resolved.method,
|
|
3003
|
-
path: resolved.path,
|
|
3004
|
-
name: err instanceof Error ? err.name : "Error",
|
|
3005
|
-
})
|
|
3006
|
-
}
|
|
3007
|
-
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
|
|
3008
2069
|
}
|
|
3009
2070
|
|
|
3010
2071
|
/** The narrowest bare route: a syntactic `() => ...` handler cannot observe the context argument, so
|
|
@@ -3650,6 +2711,15 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
3650
2711
|
wrapResponse: (response: Response) => T,
|
|
3651
2712
|
): Promise<T> {
|
|
3652
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
|
+
}
|
|
3653
2723
|
if (entry.schema?.body !== undefined) {
|
|
3654
2724
|
const bodyError = await this.readAndValidateBody(source, entry, ctx)
|
|
3655
2725
|
if (bodyError !== undefined) return wrapResponse(bodyError)
|
|
@@ -3773,11 +2843,15 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
3773
2843
|
entry: RouteEntry,
|
|
3774
2844
|
result: StandardResult<unknown>,
|
|
3775
2845
|
ctx: RawContext,
|
|
3776
|
-
kind: "body" | "query",
|
|
2846
|
+
kind: "body" | "query" | "params",
|
|
3777
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
|
+
}
|
|
3778
2853
|
if (result.issues === undefined) {
|
|
3779
|
-
|
|
3780
|
-
else ctx.query = result.value
|
|
2854
|
+
assign(result.value)
|
|
3781
2855
|
return undefined
|
|
3782
2856
|
}
|
|
3783
2857
|
const hook = entry.schema?.onValidationError ?? this.defaultOnValidationError
|
|
@@ -3786,12 +2860,16 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
3786
2860
|
const recovery = attempted instanceof Promise ? await attempted : attempted
|
|
3787
2861
|
if (recovery === undefined) return validationError(result.issues)
|
|
3788
2862
|
if (recovery instanceof Response) return recovery
|
|
3789
|
-
const schema =
|
|
2863
|
+
const schema =
|
|
2864
|
+
kind === "body"
|
|
2865
|
+
? entry.schema?.body
|
|
2866
|
+
: kind === "query"
|
|
2867
|
+
? entry.schema?.query
|
|
2868
|
+
: entry.schema?.params
|
|
3790
2869
|
const retried = schema!["~standard"].validate(recovery)
|
|
3791
2870
|
const settled = retried instanceof Promise ? await retried : retried
|
|
3792
2871
|
if (settled.issues !== undefined) return validationError(settled.issues)
|
|
3793
|
-
|
|
3794
|
-
else ctx.query = settled.value
|
|
2872
|
+
assign(settled.value)
|
|
3795
2873
|
return undefined
|
|
3796
2874
|
}
|
|
3797
2875
|
|
|
@@ -3867,7 +2945,16 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
3867
2945
|
* route selection/upgrade. Named wildcards also stay on the fallback until Bun exposes their raw
|
|
3868
2946
|
* capture semantics; static and `:param` routes take the native lane. */
|
|
3869
2947
|
private buildBunNativeRoutes(): BunNativeRoutes | undefined {
|
|
3870
|
-
|
|
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
|
+
}
|
|
3871
2958
|
|
|
3872
2959
|
const routes: BunNativeRoutes = Object.create(null) as BunNativeRoutes
|
|
3873
2960
|
const mayUseFusedNative =
|
|
@@ -3938,12 +3025,12 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
3938
3025
|
// `server` 2nd arg is how Bun exposes `upgrade`); otherwise the lean request-only fetch. The
|
|
3939
3026
|
// `websocket` handlers are one shared dispatcher — each connection's `ws.data.handler` is the
|
|
3940
3027
|
// matched route's handler, set by `server.upgrade`.
|
|
3941
|
-
// With WS routes, the dispatcher comes from the
|
|
3942
|
-
// 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.
|
|
3943
3030
|
const wsHandlers =
|
|
3944
3031
|
this.wsRouteCount === 0
|
|
3945
3032
|
? undefined
|
|
3946
|
-
: (
|
|
3033
|
+
: (this.wsRuntime as WsRuntime).bunHandlers(this.topics as TopicRegistry)
|
|
3947
3034
|
const reusePort = options?.reusePort === true
|
|
3948
3035
|
// Spread rather than pass `hostname: undefined` - Bun treats an explicit undefined as a value
|
|
3949
3036
|
// on some option paths, and omitting is what selects its 0.0.0.0 default.
|
|
@@ -3955,7 +3042,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
3955
3042
|
reusePort,
|
|
3956
3043
|
...bind,
|
|
3957
3044
|
...(nativeRoutes === undefined ? {} : { routes: nativeRoutes }),
|
|
3958
|
-
fetch: (req: Request) =>
|
|
3045
|
+
fetch: (req: Request, server) =>
|
|
3046
|
+
this.fetch(req, bunPeerPlatform(server, req) as Platform<EnvOf<Ctx>>),
|
|
3959
3047
|
})
|
|
3960
3048
|
: Bun.serve<BunWsData>({
|
|
3961
3049
|
port,
|
|
@@ -4135,9 +3223,9 @@ export function toFetchHandler<Env = unknown>(
|
|
|
4135
3223
|
const pair = new Pair()
|
|
4136
3224
|
const server = pair[1]
|
|
4137
3225
|
server.accept()
|
|
4138
|
-
//
|
|
4139
|
-
//
|
|
4140
|
-
|
|
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, {
|
|
4141
3229
|
openNow: true,
|
|
4142
3230
|
pubsub: outcome.pubsub,
|
|
4143
3231
|
})
|