@nifrajs/core 1.13.0 → 2.1.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 +37 -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/capabilities.d.ts +54 -2
- package/dist/capabilities.d.ts.map +1 -1
- package/dist/capabilities.js +240 -5
- package/dist/capabilities.js.map +1 -1
- package/dist/durable-adapters.d.ts +241 -0
- package/dist/durable-adapters.d.ts.map +1 -0
- package/dist/durable-adapters.js +697 -0
- package/dist/durable-adapters.js.map +1 -0
- package/dist/durable-execution.d.ts +349 -0
- package/dist/durable-execution.d.ts.map +1 -0
- package/dist/durable-execution.js +1173 -0
- package/dist/durable-execution.js.map +1 -0
- 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/effect-lifecycle.d.ts +42 -0
- package/dist/effect-lifecycle.d.ts.map +1 -0
- package/dist/effect-lifecycle.js +66 -0
- package/dist/effect-lifecycle.js.map +1 -0
- package/dist/effect-scope.d.ts +75 -0
- package/dist/effect-scope.d.ts.map +1 -0
- package/dist/effect-scope.js +177 -0
- package/dist/effect-scope.js.map +1 -0
- package/dist/idempotency-plugin.d.ts +13 -0
- package/dist/idempotency-plugin.d.ts.map +1 -0
- package/dist/idempotency-plugin.js +16 -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/internal/capability-runtime.d.ts +62 -1
- package/dist/internal/capability-runtime.d.ts.map +1 -1
- package/dist/internal/capability-runtime.js +11 -2
- package/dist/internal/capability-runtime.js.map +1 -1
- package/dist/internal/effect-execution.d.ts +16 -0
- package/dist/internal/effect-execution.d.ts.map +1 -0
- package/dist/internal/effect-execution.js +50 -0
- package/dist/internal/effect-execution.js.map +1 -0
- package/dist/internal/safe-equal.d.ts +20 -0
- package/dist/internal/safe-equal.d.ts.map +1 -0
- package/dist/internal/safe-equal.js +31 -0
- package/dist/internal/safe-equal.js.map +1 -0
- package/dist/ledger.d.ts +14 -6
- package/dist/ledger.d.ts.map +1 -1
- package/dist/ledger.js +31 -5
- package/dist/ledger.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/reconciliation-worker.d.ts +108 -0
- package/dist/reconciliation-worker.d.ts.map +1 -0
- package/dist/reconciliation-worker.js +239 -0
- package/dist/reconciliation-worker.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 +45 -0
- package/dist/server/idempotency-lane.d.ts.map +1 -0
- package/dist/server/idempotency-lane.js +205 -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 +119 -345
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +247 -833
- 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/transport-lane.d.ts +28 -0
- package/dist/server/transport-lane.d.ts.map +1 -0
- package/dist/server/transport-lane.js +106 -0
- package/dist/server/transport-lane.js.map +1 -0
- package/dist/server/websocket.d.ts +23 -1
- package/dist/server/websocket.d.ts.map +1 -1
- package/dist/server/websocket.js +10 -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/transport-codec-rich.d.ts +8 -0
- package/dist/transport-codec-rich.d.ts.map +1 -0
- package/dist/transport-codec-rich.js +14 -0
- package/dist/transport-codec-rich.js.map +1 -0
- package/dist/transport-codec.d.ts +31 -0
- package/dist/transport-codec.d.ts.map +1 -0
- package/dist/transport-codec.js +190 -0
- package/dist/transport-codec.js.map +1 -0
- package/dist/transport-plugin.d.ts +3 -0
- package/dist/transport-plugin.d.ts.map +1 -0
- package/dist/transport-plugin.js +3 -0
- package/dist/transport-plugin.js.map +1 -0
- package/dist/wire.d.ts +58 -0
- package/dist/wire.d.ts.map +1 -0
- package/dist/wire.js +445 -0
- package/dist/wire.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 +122 -6
- package/src/assurance.ts +36 -1
- package/src/capabilities.ts +306 -5
- package/src/durable-adapters.ts +1039 -0
- package/src/durable-execution.ts +1676 -0
- package/src/effect-ledger.ts +9 -0
- package/src/effect-lifecycle.ts +120 -0
- package/src/effect-scope.ts +267 -0
- package/src/idempotency-plugin.ts +17 -0
- package/src/index.ts +6 -133
- package/src/internal/capability-runtime.ts +87 -1
- package/src/internal/effect-execution.ts +70 -0
- package/src/internal/safe-equal.ts +27 -0
- package/src/ledger.ts +45 -11
- package/src/mcp.ts +65 -0
- package/src/node-direct.ts +37 -0
- package/src/reconciliation-worker.ts +372 -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 +321 -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 +94 -0
- package/src/server/runtime-core.ts +74 -0
- package/src/server/server-types.ts +204 -0
- package/src/server/server.ts +448 -1314
- package/src/server/sse-hook.ts +14 -0
- package/src/server/sse.ts +26 -0
- package/src/server/transport-lane.ts +141 -0
- package/src/server/websocket.ts +35 -1
- package/src/server/ws-hook.ts +8 -19
- package/src/server.ts +92 -0
- package/src/transport-codec-rich.ts +20 -0
- package/src/transport-codec.ts +250 -0
- package/src/transport-plugin.ts +2 -0
- package/src/wire.ts +474 -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
|
@@ -6,40 +6,26 @@ import {
|
|
|
6
6
|
NIFRA_DEADLINE_HEADER,
|
|
7
7
|
type RequestBudget,
|
|
8
8
|
} from "../budget.ts"
|
|
9
|
+
import type { EffectLifecycleObserver } from "../effect-lifecycle.ts"
|
|
9
10
|
import { FrameworkError, RouteConfigError } from "../errors.ts"
|
|
10
11
|
import {
|
|
11
|
-
|
|
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
|
-
import {
|
|
12
|
+
type AroundCapabilityOptions,
|
|
24
13
|
CAPABILITY_GUARD,
|
|
14
|
+
type CapabilityInterceptor,
|
|
25
15
|
type CapabilityUseEvent,
|
|
26
16
|
createCapabilityGuard,
|
|
17
|
+
DEFAULT_CAPABILITY_INTERCEPTOR_TIMEOUT_MS,
|
|
27
18
|
normalizeRouteCapabilities,
|
|
19
|
+
type RegisteredCapabilityInterceptor,
|
|
28
20
|
} from "../internal/capability-runtime.ts"
|
|
29
21
|
import {
|
|
30
22
|
type AssuranceDeclaration,
|
|
31
|
-
type AssuranceEvidence,
|
|
32
23
|
assuranceDeclarationsOf,
|
|
33
24
|
assuranceEvidenceFor,
|
|
34
25
|
NIFRA_ASSURANCE_IDS,
|
|
26
|
+
validEvidenceId,
|
|
35
27
|
} 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"
|
|
28
|
+
import type { RequestLedger } from "../ledger.ts"
|
|
43
29
|
import {
|
|
44
30
|
type CompiledRoutePattern,
|
|
45
31
|
compileRoutePattern,
|
|
@@ -52,12 +38,73 @@ import type {
|
|
|
52
38
|
StandardResult,
|
|
53
39
|
StandardSchemaV1,
|
|
54
40
|
} from "../schema/standard.ts"
|
|
55
|
-
import {
|
|
41
|
+
import { parseContentLength } from "./body.ts"
|
|
42
|
+
import { type ClientIpTrust, resolveClientIp } from "./client-ip.ts"
|
|
56
43
|
import type { Context, Platform, ResponseControls, RouteSchema } from "./context.ts"
|
|
57
|
-
import {
|
|
44
|
+
import { jsonError, pathnameOf, urlPartsOf } from "./http.ts"
|
|
45
|
+
import type { NodeServeOutcome } from "./node-outcome.ts"
|
|
46
|
+
import type { NodeOutcomeRuntime } from "./node-outcome-hook.ts"
|
|
47
|
+
import {
|
|
48
|
+
isUrlEncodedForm,
|
|
49
|
+
type QueryValue,
|
|
50
|
+
queryObjectOf,
|
|
51
|
+
readBoundedForm,
|
|
52
|
+
searchOf,
|
|
53
|
+
} from "./query.ts"
|
|
54
|
+
import { RequestContext, readBoundedJsonSource } from "./request-context.ts"
|
|
55
|
+
import { fusedRespond, fusedRespondNoSet, toResponse } from "./respond.ts"
|
|
56
|
+
import {
|
|
57
|
+
CONTEXT_SEARCH,
|
|
58
|
+
CONTEXT_SET,
|
|
59
|
+
EMPTY_RESPONSE_CONTROLS,
|
|
60
|
+
getNeverAbortSignal,
|
|
61
|
+
getUnboundedRequestBudget,
|
|
62
|
+
type HandlerResult,
|
|
63
|
+
headerOf,
|
|
64
|
+
requestOf,
|
|
65
|
+
} from "./runtime-core.ts"
|
|
66
|
+
|
|
67
|
+
// NodeServeOutcome (the nifra<->node bridge render form) now lives in `./node-outcome.ts`; re-exported
|
|
68
|
+
// so existing importers keep resolving it from the server module.
|
|
69
|
+
export type { NodeServeOutcome }
|
|
70
|
+
|
|
71
|
+
import type { IdempotencyRuntime, ResolvedIdempotency } from "./idempotency-lane.ts"
|
|
72
|
+
import {
|
|
73
|
+
INSTALL_EFFECT_LEDGER,
|
|
74
|
+
INSTALL_IDEMPOTENCY,
|
|
75
|
+
INSTALL_MCP,
|
|
76
|
+
INSTALL_NODE_DIRECT,
|
|
77
|
+
INSTALL_SSE,
|
|
78
|
+
INSTALL_WS,
|
|
79
|
+
} from "./install.ts"
|
|
80
|
+
import type { EffectLedgerRuntime, ResolvedEffectLedger } from "./ledger-lane.ts"
|
|
58
81
|
import { jsonLogger, type Logger } from "./logger.ts"
|
|
59
|
-
import type {
|
|
60
|
-
import
|
|
82
|
+
import type { McpRuntime } from "./mcp-hook.ts"
|
|
83
|
+
import type { IdentityPlugin } from "./plugin.ts"
|
|
84
|
+
import type {
|
|
85
|
+
AddRoute,
|
|
86
|
+
EmptyRegistry,
|
|
87
|
+
OutputOf,
|
|
88
|
+
Registry,
|
|
89
|
+
RouteInfoFor,
|
|
90
|
+
WsRouteInfoFor,
|
|
91
|
+
} from "./registry.ts"
|
|
92
|
+
import type {
|
|
93
|
+
AdmissionController,
|
|
94
|
+
AdmissionDecision,
|
|
95
|
+
McpPromptDescriptor,
|
|
96
|
+
McpResourceDescriptor,
|
|
97
|
+
Middleware,
|
|
98
|
+
PromptArgument,
|
|
99
|
+
PromptMessage,
|
|
100
|
+
ResponseFinalization,
|
|
101
|
+
RouteDescriptor,
|
|
102
|
+
RunningServer,
|
|
103
|
+
ServerOptions,
|
|
104
|
+
ToolAnnotations,
|
|
105
|
+
} from "./server-types.ts"
|
|
106
|
+
import type { SSEInit, TypedSSEStream } from "./sse.ts"
|
|
107
|
+
import type { SseRuntime } from "./sse-hook.ts"
|
|
61
108
|
import type {
|
|
62
109
|
StandardWebSocket,
|
|
63
110
|
TopicRegistry,
|
|
@@ -66,9 +113,9 @@ import type {
|
|
|
66
113
|
WebSocketUpgradeOutcome,
|
|
67
114
|
} from "./websocket.ts"
|
|
68
115
|
import type { BunWsData } from "./ws-bun.ts"
|
|
69
|
-
import {
|
|
116
|
+
import type { WsRuntime } from "./ws-hook.ts"
|
|
70
117
|
|
|
71
|
-
type MaybePromise<T> = T | Promise<T>
|
|
118
|
+
export type MaybePromise<T> = T | Promise<T>
|
|
72
119
|
|
|
73
120
|
/**
|
|
74
121
|
* Internal request view. A real Web `Request` already satisfies this shape, so Web/edge runtimes pass
|
|
@@ -90,17 +137,6 @@ export interface RequestSource {
|
|
|
90
137
|
readonly request?: Request
|
|
91
138
|
}
|
|
92
139
|
|
|
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
140
|
/** The empty context extension. `NonNullable<unknown>` is `{}` without tripping noBannedTypes. */
|
|
105
141
|
type EmptyContext = NonNullable<unknown>
|
|
106
142
|
|
|
@@ -111,42 +147,20 @@ type EmptyContext = NonNullable<unknown>
|
|
|
111
147
|
*/
|
|
112
148
|
type EnvOf<Ctx> = Ctx extends { readonly env: infer E } ? E : unknown
|
|
113
149
|
|
|
114
|
-
/** A handler returns a `Response` (used as-is) or any value (serialized to JSON). */
|
|
115
|
-
type HandlerResult = Response | unknown
|
|
116
150
|
type ContextlessHandler = () => MaybePromise<HandlerResult>
|
|
117
151
|
|
|
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
152
|
const functionToString = Function.prototype.toString
|
|
122
153
|
const CONTEXTLESS_ARROW = /^(?:async\s*)?\(\s*\)\s*(?::[\s\S]*?)?=>/
|
|
123
154
|
|
|
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
155
|
/** Internal, path-erased runtime context. The typed `Context<Path, S>` is a structural view of this. */
|
|
144
|
-
interface RawContext {
|
|
156
|
+
export interface RawContext {
|
|
145
157
|
readonly req: Request
|
|
146
158
|
readonly request: Request
|
|
147
159
|
readonly json: (body: unknown, init?: ResponseInit | number) => Response
|
|
148
160
|
readonly text: (body: string, init?: ResponseInit | number) => Response
|
|
149
|
-
|
|
161
|
+
// Writable: the lifecycle replaces it with the validated/coerced value when a `params` schema is
|
|
162
|
+
// declared (handlers still see it `readonly` via the public `Context` interface).
|
|
163
|
+
params: Record<string, string>
|
|
150
164
|
query: unknown
|
|
151
165
|
readonly cookies: Readonly<Record<string, string>>
|
|
152
166
|
body: unknown
|
|
@@ -156,17 +170,12 @@ interface RawContext {
|
|
|
156
170
|
readonly signal: AbortSignal
|
|
157
171
|
readonly budget: RequestBudget
|
|
158
172
|
readonly env: unknown
|
|
173
|
+
readonly clientIp: string | undefined
|
|
159
174
|
readonly waitUntil: (promise: Promise<unknown>) => void
|
|
160
175
|
readonly boundedBody: (maxBytes?: number) => Promise<Uint8Array>
|
|
161
176
|
readonly boundedJson: <T = unknown>(maxBytes?: number) => Promise<T>
|
|
162
177
|
}
|
|
163
178
|
|
|
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
179
|
type InternalHandler = (ctx: RawContext) => MaybePromise<HandlerResult>
|
|
171
180
|
|
|
172
181
|
/** Broad shape so the implementation signature is compatible with both typed overloads. */
|
|
@@ -183,27 +192,6 @@ type RawOnRequest = (req: Request, platform?: Platform) => MaybePromise<OnReques
|
|
|
183
192
|
type RawOnResponse = (response: Response, req: Request) => MaybePromise<Response>
|
|
184
193
|
type RawOnResponseFinalized = (outcome: ResponseFinalization, req: Request) => MaybePromise<void>
|
|
185
194
|
|
|
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
195
|
type RouteExecutionRunner = <T, R extends Registry, Ctx>(
|
|
208
196
|
runtime: Server<R, Ctx>,
|
|
209
197
|
entry: RouteEntry,
|
|
@@ -340,9 +328,20 @@ interface WsEntry {
|
|
|
340
328
|
readonly handler: WebSocketHandler
|
|
341
329
|
}
|
|
342
330
|
|
|
343
|
-
/** Structural view of the Bun `Server`
|
|
331
|
+
/** Structural view of the Bun `Server` the `fetch` 2nd arg exposes (`upgrade` + the socket peer). */
|
|
344
332
|
interface BunUpgradeServer {
|
|
345
333
|
upgrade(request: Request, options?: { data?: BunWsData }): boolean
|
|
334
|
+
requestIP(request: Request): { readonly address: string } | null
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** The socket peer Bun observed, as a `Platform` for the request lifecycle (`undefined` if unknown).
|
|
338
|
+
* Typed structurally on `requestIP` alone so any Bun `Server` (WS or not) satisfies it. */
|
|
339
|
+
function bunPeerPlatform(
|
|
340
|
+
server: { requestIP(request: Request): { readonly address: string } | null },
|
|
341
|
+
req: Request,
|
|
342
|
+
): Platform | undefined {
|
|
343
|
+
const address = server.requestIP(req)?.address
|
|
344
|
+
return address === undefined ? undefined : { clientIp: address }
|
|
346
345
|
}
|
|
347
346
|
|
|
348
347
|
type BunNativeHandler = (request: Request) => MaybePromise<Response>
|
|
@@ -353,12 +352,31 @@ type BunRequestWithParams = Request & { readonly params?: Record<string, string>
|
|
|
353
352
|
const WS_PASS: WebSocketUpgradeOutcome = { kind: "pass" }
|
|
354
353
|
|
|
355
354
|
/** `app.ws()` (and everything downstream of it) needs the runtime `@nifrajs/core/ws` registers. */
|
|
356
|
-
function requireWsRuntime(): WsRuntime {
|
|
357
|
-
const runtime = getWsRuntime()
|
|
355
|
+
function requireWsRuntime(runtime: WsRuntime | undefined): WsRuntime {
|
|
358
356
|
if (runtime === undefined) {
|
|
359
357
|
throw new FrameworkError(
|
|
360
358
|
"WS_RUNTIME_MISSING",
|
|
361
|
-
|
|
359
|
+
"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.",
|
|
360
|
+
)
|
|
361
|
+
}
|
|
362
|
+
return runtime
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function requireSseRuntime(runtime: SseRuntime | undefined): SseRuntime {
|
|
366
|
+
if (runtime === undefined) {
|
|
367
|
+
throw new FrameworkError(
|
|
368
|
+
"SSE_RUNTIME_MISSING",
|
|
369
|
+
"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.",
|
|
370
|
+
)
|
|
371
|
+
}
|
|
372
|
+
return runtime
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function requireMcpRuntime(runtime: McpRuntime | undefined): McpRuntime {
|
|
376
|
+
if (runtime === undefined) {
|
|
377
|
+
throw new FrameworkError(
|
|
378
|
+
"MCP_RUNTIME_MISSING",
|
|
379
|
+
"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
380
|
)
|
|
363
381
|
}
|
|
364
382
|
return runtime
|
|
@@ -381,202 +399,21 @@ export type Handler<
|
|
|
381
399
|
Ctx = EmptyContext,
|
|
382
400
|
> = (ctx: Context<Path, S> & Ctx) => MaybePromise<ResponseOf<S>>
|
|
383
401
|
|
|
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
|
|
402
|
+
// Route/option/descriptor + middleware-bundle types now live in `./server-types.ts`; re-exported so
|
|
403
|
+
// existing importers keep resolving them from the server module.
|
|
404
|
+
export type {
|
|
405
|
+
AdmissionController,
|
|
406
|
+
AdmissionDecision,
|
|
407
|
+
McpPromptDescriptor,
|
|
408
|
+
McpResourceDescriptor,
|
|
409
|
+
Middleware,
|
|
410
|
+
PromptArgument,
|
|
411
|
+
PromptMessage,
|
|
412
|
+
ResponseFinalization,
|
|
413
|
+
RouteDescriptor,
|
|
414
|
+
RunningServer,
|
|
415
|
+
ServerOptions,
|
|
416
|
+
ToolAnnotations,
|
|
580
417
|
}
|
|
581
418
|
|
|
582
419
|
// A plugin operates over arbitrary Server shapes; `any` here is the standard framework escape hatch
|
|
@@ -584,119 +421,19 @@ export interface ResponseFinalization {
|
|
|
584
421
|
// biome-ignore lint/suspicious/noExplicitAny: plugins are generic over any Server's Registry/Context
|
|
585
422
|
export type AnyServer = Server<any, any>
|
|
586
423
|
|
|
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
|
|
424
|
+
// Plugin definers + their types now live in `./plugin.ts`; re-exported here so `.use()` callers and
|
|
425
|
+
// existing importers keep resolving them from the server module.
|
|
426
|
+
export {
|
|
427
|
+
defineIdentityPlugin,
|
|
428
|
+
definePlugin,
|
|
429
|
+
defineRouterPlugin,
|
|
430
|
+
type NifraPlugin,
|
|
431
|
+
} from "./plugin.ts"
|
|
432
|
+
export type { IdentityPlugin }
|
|
672
433
|
|
|
673
434
|
const DEFAULT_MAX_BODY_BYTES = 1_000_000
|
|
674
435
|
const DEFAULT_DRAIN_MS = 10_000
|
|
675
436
|
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
437
|
|
|
701
438
|
/** Same-origin check for a WebSocket handshake (CSWSH default): the `Origin`'s host[:port] must equal the
|
|
702
439
|
* request's own host (from `req.url`, which the runtime builds from the `Host` header). Scheme differs
|
|
@@ -717,157 +454,12 @@ function validationError(issues: ReadonlyArray<StandardIssue>): Response {
|
|
|
717
454
|
return Response.json({ ok: false, error: "validation", issues: serialized }, { status: 422 })
|
|
718
455
|
}
|
|
719
456
|
|
|
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
|
-
}
|
|
457
|
+
// `jsonError`, `urlPartsOf`, `pathnameOf` moved to `./http.ts` (a dependency-free leaf shared with the
|
|
458
|
+
// opt-in request lanes); re-exported so existing importers keep resolving from here.
|
|
459
|
+
export { pathnameOf, urlPartsOf } from "./http.ts"
|
|
460
|
+
// Query-string + urlencoded-form parsing now lives in `./query.ts`; re-exported so existing
|
|
461
|
+
// importers keep resolving `searchOf`/`queryObjectOf`/`QueryValue` from here.
|
|
462
|
+
export { type QueryValue, queryObjectOf, searchOf }
|
|
871
463
|
|
|
872
464
|
function hasReplacementParam(params: Record<string, string>): boolean {
|
|
873
465
|
for (const key in params) {
|
|
@@ -878,48 +470,12 @@ function hasReplacementParam(params: Record<string, string>): boolean {
|
|
|
878
470
|
|
|
879
471
|
/** `ctx.set` carrying the lazy backings (`_headers`, `_cookies`) so `toResponse` can skip allocating
|
|
880
472
|
* anything when no handler touched `c.set.*`. Server-internal. */
|
|
881
|
-
type CtxSet = ResponseControls & {
|
|
473
|
+
export type CtxSet = ResponseControls & {
|
|
882
474
|
_headers?: Record<string, string>
|
|
883
475
|
/** Accumulated `Set-Cookie` values — a list, since a `Record` would collapse multiple cookies. */
|
|
884
476
|
_cookies?: string[]
|
|
885
477
|
}
|
|
886
478
|
|
|
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
479
|
function responseSet(ctx: RawContext): CtxSet {
|
|
924
480
|
return ctx[CONTEXT_SET]() ?? EMPTY_RESPONSE_CONTROLS
|
|
925
481
|
}
|
|
@@ -933,407 +489,9 @@ function isContextlessNoArgArrow(handler: (context: never) => unknown): boolean
|
|
|
933
489
|
}
|
|
934
490
|
}
|
|
935
491
|
|
|
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
492
|
// Stable module-level finalizers so `fetch`/`resolveNode` allocate no per-request closures.
|
|
1285
493
|
const IDENTITY_RESPONSE = (response: Response): Response => response
|
|
1286
494
|
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
495
|
|
|
1338
496
|
/**
|
|
1339
497
|
* The inline server. Routes are chainable and fully type-inferred. `derive`/
|
|
@@ -1355,6 +513,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1355
513
|
private readonly maxBodyBytes: number
|
|
1356
514
|
private readonly wsMaxPayloadBytes: number
|
|
1357
515
|
private readonly requestTimeoutMs: number
|
|
516
|
+
/** Opt-in caller-IP trust declaration; `undefined` = socket peer only, no forwarded header believed. */
|
|
517
|
+
private readonly clientIpTrust: ClientIpTrust | undefined
|
|
1358
518
|
private readonly acceptInboundDeadlines: boolean
|
|
1359
519
|
private readonly maxInboundDeadlineMs: number
|
|
1360
520
|
private readonly deadlineAdmissionOptions: DeadlineAdmissionOptions
|
|
@@ -1362,13 +522,22 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1362
522
|
/** Capacity-admission gate; `undefined` = off (the request path pays nothing). */
|
|
1363
523
|
private readonly capacityGate: AdmissionController | undefined
|
|
1364
524
|
private readonly onCapabilityUse: ((event: CapabilityUseEvent) => void) | undefined
|
|
1365
|
-
|
|
1366
|
-
private readonly
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
525
|
+
private readonly capabilityInterceptors: RegisteredCapabilityInterceptor[]
|
|
526
|
+
private readonly capabilityObservers: EffectLifecycleObserver[]
|
|
527
|
+
/** The installed effect-ledger runtime (owns the sink + per-route resolution + settle), or
|
|
528
|
+
* `undefined` when the effect-ledger plugin is not installed. */
|
|
529
|
+
private effectLedgerRuntime: EffectLedgerRuntime | undefined
|
|
530
|
+
/** The installed idempotency runtime (owns the app-wide default store + the dedupe lane), or
|
|
531
|
+
* `undefined` when the idempotency plugin is not installed. */
|
|
532
|
+
private idempotencyRuntime: IdempotencyRuntime | undefined
|
|
533
|
+
/** Installed opt-in runtime for `.tool()`/`.resource()`/`.prompt()`; `undefined` until `.use(mcp())`. */
|
|
534
|
+
private mcpRuntime: McpRuntime | undefined
|
|
535
|
+
/** Installed Node-direct renderer for direct `resolveNode()` callers; `undefined` until `.use(nodeDirect())`. */
|
|
536
|
+
private nodeOutcomeRuntime: NodeOutcomeRuntime | undefined
|
|
537
|
+
/** Installed streaming runtime for `.sse()` routes; `undefined` until `.use(streaming())`. */
|
|
538
|
+
private sseRuntime: SseRuntime | undefined
|
|
539
|
+
/** Installed WebSocket runtime for `.ws()` routes; `undefined` until `.use(websocket())`. */
|
|
540
|
+
private wsRuntime: WsRuntime | undefined
|
|
1372
541
|
private readonly logger: Logger
|
|
1373
542
|
/** App-wide validation-error fallback; a route's own `schema.onValidationError` takes precedence. */
|
|
1374
543
|
private readonly defaultOnValidationError?: RouteSchema["onValidationError"]
|
|
@@ -1401,6 +570,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1401
570
|
this.maxBodyBytes = options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES
|
|
1402
571
|
this.wsMaxPayloadBytes = options.wsMaxPayloadBytes ?? this.maxBodyBytes
|
|
1403
572
|
this.requestTimeoutMs = options.requestTimeoutMs ?? 0
|
|
573
|
+
this.clientIpTrust = options.clientIp
|
|
1404
574
|
this.acceptInboundDeadlines = options.acceptInboundDeadlines ?? false
|
|
1405
575
|
this.maxInboundDeadlineMs = options.maxInboundDeadlineMs ?? 30_000
|
|
1406
576
|
this.deadlineAdmissionOptions = Object.freeze({
|
|
@@ -1416,25 +586,14 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1416
586
|
this.gracefulSignals = options.gracefulSignals ?? false
|
|
1417
587
|
this.capacityGate = options.admission
|
|
1418
588
|
this.onCapabilityUse = options.onCapabilityUse
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
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
|
|
589
|
+
this.capabilityInterceptors = []
|
|
590
|
+
this.capabilityObservers = []
|
|
591
|
+
// The effect-ledger runtime is installed by `.use(effectLedger())`; a bare app never imports it, so
|
|
592
|
+
// the ledger machinery tree-shakes out. Capability-declaring routes simply carry no ledger without it.
|
|
593
|
+
this.effectLedgerRuntime = undefined
|
|
594
|
+
// The idempotency runtime is installed by `.use(idempotency())`; a bare app never imports it, so the
|
|
595
|
+
// dedupe machinery tree-shakes out. A route that declares idempotency without it is a build error.
|
|
596
|
+
this.idempotencyRuntime = undefined
|
|
1438
597
|
this.logger = options.logger ?? jsonLogger()
|
|
1439
598
|
this.defaultOnValidationError = options.onValidationError
|
|
1440
599
|
this.bunServer = undefined
|
|
@@ -1509,6 +668,40 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1509
668
|
return this
|
|
1510
669
|
}
|
|
1511
670
|
|
|
671
|
+
/**
|
|
672
|
+
* Asynchronously admit each subsequent `executeCapability()` call before its owned effect runs.
|
|
673
|
+
* Interceptors receive token-only metadata plus an abort signal and must call `next()` exactly once
|
|
674
|
+
* to admit. Returning without `next()`, timing out, aborting, or throwing fails the effect closed.
|
|
675
|
+
*/
|
|
676
|
+
aroundCapability(
|
|
677
|
+
interceptor: CapabilityInterceptor,
|
|
678
|
+
options: AroundCapabilityOptions = {},
|
|
679
|
+
): this {
|
|
680
|
+
this.assertConfigurable("aroundCapability()")
|
|
681
|
+
if (typeof interceptor !== "function") {
|
|
682
|
+
throw new TypeError("aroundCapability interceptor must be a function")
|
|
683
|
+
}
|
|
684
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_CAPABILITY_INTERCEPTOR_TIMEOUT_MS
|
|
685
|
+
if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0) {
|
|
686
|
+
throw new RangeError("aroundCapability timeoutMs must be a positive safe integer")
|
|
687
|
+
}
|
|
688
|
+
// JS timers wrap larger delays to ~1ms, which would unexpectedly deny every effect.
|
|
689
|
+
this.capabilityInterceptors.push(
|
|
690
|
+
Object.freeze({ interceptor, timeoutMs: Math.min(timeoutMs, 2_147_483_647) }),
|
|
691
|
+
)
|
|
692
|
+
return this
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/** Observe token-only admission/execution lifecycle events for subsequent capability routes. */
|
|
696
|
+
observeCapability(observer: EffectLifecycleObserver): this {
|
|
697
|
+
this.assertConfigurable("observeCapability()")
|
|
698
|
+
if (typeof observer !== "function") {
|
|
699
|
+
throw new TypeError("observeCapability observer must be a function")
|
|
700
|
+
}
|
|
701
|
+
this.capabilityObservers.push(observer)
|
|
702
|
+
return this
|
|
703
|
+
}
|
|
704
|
+
|
|
1512
705
|
/** Transform the handler's result before it is serialized. Order-scoped. */
|
|
1513
706
|
afterHandle(fn: (result: unknown, context: Context & Ctx) => MaybePromise<unknown>): this {
|
|
1514
707
|
this.assertConfigurable("afterHandle()")
|
|
@@ -1629,7 +822,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1629
822
|
* JSON-serialized into the SSE `data:` field. The typed client sees the marker and grows a
|
|
1630
823
|
* `.subscribe(onEvent)` for the route with the same payload type — end-to-end typed streaming.
|
|
1631
824
|
*
|
|
1632
|
-
*
|
|
825
|
+
* import { streaming } from "@nifrajs/core/sse" // .use(streaming()) enables .sse()
|
|
826
|
+
* const app = server().use(streaming()).sse("/feed", { sse: t.object({ id: t.integer(), title: t.string() }) },
|
|
1633
827
|
* async (c, stream) => {
|
|
1634
828
|
* stream.send({ id: 1, title: "hello" }) // typed
|
|
1635
829
|
* await waitForDisconnect(stream.signal)
|
|
@@ -1652,7 +846,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1652
846
|
init?: SSEInit,
|
|
1653
847
|
): Server<AddRoute<R, "GET", Path, RouteInfoFor<Path, S, Response>>, Ctx> {
|
|
1654
848
|
const handler = (context: Context<Path, S> & Ctx): Response =>
|
|
1655
|
-
|
|
849
|
+
requireSseRuntime(this.sseRuntime).response(context, (stream) => run(context, stream), init)
|
|
1656
850
|
return this.route("GET", path, schema, handler as unknown as ErasedHandler) as Server<
|
|
1657
851
|
AddRoute<R, "GET", Path, RouteInfoFor<Path, S, Response>>,
|
|
1658
852
|
Ctx
|
|
@@ -1727,6 +921,19 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1727
921
|
return this.route("DELETE", path, schemaOrHandler, handler)
|
|
1728
922
|
}
|
|
1729
923
|
|
|
924
|
+
/**
|
|
925
|
+
* Declare an **MCP tool** an agent can call (via `nifra mcp`, or a mounted MCP endpoint): a typed
|
|
926
|
+
* `POST /_nifra/tool/<name>` route whose `input`/`output` schemas contract the call and surface in
|
|
927
|
+
* `tools/list`. Requires `.use(mcp())` - without it, `.tool()` is a registration error, so an
|
|
928
|
+
* ordinary HTTP app never bundles the MCP wiring. Siblings: {@link resource}, {@link prompt}.
|
|
929
|
+
*
|
|
930
|
+
* import { mcp } from "@nifrajs/core/mcp"
|
|
931
|
+
* const app = server().use(mcp()).tool(
|
|
932
|
+
* "search",
|
|
933
|
+
* { description: "Search posts", input: t.object({ q: t.string() }) },
|
|
934
|
+
* ({ q }) => findPosts(q),
|
|
935
|
+
* )
|
|
936
|
+
*/
|
|
1730
937
|
tool<
|
|
1731
938
|
Name extends string,
|
|
1732
939
|
S extends {
|
|
@@ -1768,24 +975,17 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1768
975
|
},
|
|
1769
976
|
handler: (input: unknown, ctx: Context & Ctx) => unknown,
|
|
1770
977
|
): 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)
|
|
978
|
+
const plan = requireMcpRuntime(this.mcpRuntime).tool(
|
|
979
|
+
name,
|
|
980
|
+
config,
|
|
981
|
+
handler as (input: unknown, context: Context) => unknown,
|
|
982
|
+
)
|
|
983
|
+
this.register("POST", plan.path, plan.schema, plan.run as (context: never) => unknown)
|
|
1780
984
|
// Tag the just-registered descriptor as an MCP tool. `tool` is readonly on RouteDescriptor (an
|
|
1781
985
|
// introspection field), so write it through a narrow mutable view — not `any`.
|
|
1782
986
|
const lastRoute = this.catalog.lastDescriptor()
|
|
1783
987
|
if (lastRoute) {
|
|
1784
|
-
;(lastRoute as { tool?: RouteDescriptor["tool"] }).tool =
|
|
1785
|
-
name,
|
|
1786
|
-
description: config.description,
|
|
1787
|
-
...(config.annotations !== undefined ? { annotations: config.annotations } : {}),
|
|
1788
|
-
}
|
|
988
|
+
;(lastRoute as { tool?: RouteDescriptor["tool"] }).tool = plan.descriptor
|
|
1789
989
|
}
|
|
1790
990
|
return this as unknown as Server<Registry, Ctx>
|
|
1791
991
|
}
|
|
@@ -1802,13 +1002,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1802
1002
|
read: McpResourceDescriptor["read"],
|
|
1803
1003
|
): Server<R, Ctx> {
|
|
1804
1004
|
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
|
-
})
|
|
1005
|
+
this.mcpResourceList.push(requireMcpRuntime(this.mcpRuntime).resource(uri, config, read))
|
|
1812
1006
|
return this
|
|
1813
1007
|
}
|
|
1814
1008
|
|
|
@@ -1822,12 +1016,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1822
1016
|
handler: McpPromptDescriptor["handler"],
|
|
1823
1017
|
): Server<R, Ctx> {
|
|
1824
1018
|
this.assertConfigurable("prompt()")
|
|
1825
|
-
this.mcpPromptList.push(
|
|
1826
|
-
name,
|
|
1827
|
-
description: config.description,
|
|
1828
|
-
...(config.arguments !== undefined ? { arguments: config.arguments } : {}),
|
|
1829
|
-
handler,
|
|
1830
|
-
})
|
|
1019
|
+
this.mcpPromptList.push(requireMcpRuntime(this.mcpRuntime).prompt(name, config, handler))
|
|
1831
1020
|
return this
|
|
1832
1021
|
}
|
|
1833
1022
|
|
|
@@ -1848,16 +1037,29 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1848
1037
|
* adapter (`listen()`, `@nifrajs/node`, `@nifrajs/deno`, `toFetchHandler`) — not by bare `app.fetch`, which
|
|
1849
1038
|
* has no socket (a WS path through `app.fetch` is a normal HTTP response).
|
|
1850
1039
|
*
|
|
1040
|
+
* The route also enters the type-level registry (under the pseudo-method `"WS"`), so the typed
|
|
1041
|
+
* client grows a `.ws()` handle for it: `messageSchema` types what the client may `send`,
|
|
1042
|
+
* `sendSchema` types the frames it receives. Passing explicit type arguments (`ws<MyData>(…)`)
|
|
1043
|
+
* defeats path-literal inference and skips the registry entry - the route still serves, it is just
|
|
1044
|
+
* invisible to `client<App>`; prefer typing `data` via `upgrade()`'s return.
|
|
1045
|
+
*
|
|
1851
1046
|
* app.ws("/chat", { open: (ws) => ws.send("hi"), message: (ws, data) => ws.send(data) })
|
|
1852
1047
|
*/
|
|
1853
|
-
ws<
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1048
|
+
ws<
|
|
1049
|
+
Data = unknown,
|
|
1050
|
+
Schema extends StandardSchemaV1 | undefined = undefined,
|
|
1051
|
+
Send extends StandardSchemaV1 | undefined = undefined,
|
|
1052
|
+
Path extends string = string,
|
|
1053
|
+
>(
|
|
1054
|
+
path: Path,
|
|
1055
|
+
handler: WebSocketHandler<Data, EnvOf<Ctx>, Schema, Send>,
|
|
1056
|
+
): string extends Path
|
|
1057
|
+
? Server<R, Ctx>
|
|
1058
|
+
: Server<AddRoute<R, "WS", Path, WsRouteInfoFor<Path, Schema, Send>>, Ctx> {
|
|
1857
1059
|
this.assertConfigurable("ws()")
|
|
1858
1060
|
// Boot-time guard: the WS runtime is a subpath (`@nifrajs/core/ws`) so no-WebSocket apps don't
|
|
1859
1061
|
// bundle it. Registration is the loud, early failure point — never the first connection.
|
|
1860
|
-
const runtime = requireWsRuntime()
|
|
1062
|
+
const runtime = requireWsRuntime(this.wsRuntime)
|
|
1861
1063
|
this.topics ??= runtime.createTopics()
|
|
1862
1064
|
// A `messageSchema` wraps `message` with validation once, here — every adapter then dispatches
|
|
1863
1065
|
// already-validated, typed messages (Bun/Deno/Node/Workers) with no per-adapter code.
|
|
@@ -1865,7 +1067,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1865
1067
|
handler: runtime.wrapHandler(handler as WebSocketHandler),
|
|
1866
1068
|
})
|
|
1867
1069
|
this.wsRouteCount += 1
|
|
1868
|
-
return this
|
|
1070
|
+
return this as never
|
|
1869
1071
|
}
|
|
1870
1072
|
|
|
1871
1073
|
/**
|
|
@@ -1961,21 +1163,34 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1961
1163
|
method,
|
|
1962
1164
|
path,
|
|
1963
1165
|
this.onCapabilityUse,
|
|
1166
|
+
this.idempotencyRuntime?.trackEffect,
|
|
1167
|
+
Object.freeze([...this.capabilityInterceptors]),
|
|
1168
|
+
Object.freeze([...this.capabilityObservers]),
|
|
1964
1169
|
)
|
|
1965
1170
|
}
|
|
1966
1171
|
const hasDecorations = Reflect.ownKeys(routeDecorations).length > 0
|
|
1967
1172
|
// An idempotency route runs a dedupe lane that must buffer the body and capture the response, so it
|
|
1968
1173
|
// 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
|
-
//
|
|
1174
|
+
// through `fetchMatched`, where the dedupe wrapper lives). Fail closed: a route may not declare
|
|
1175
|
+
// idempotency unless the idempotency runtime is installed, so the safety gate can never be silently
|
|
1176
|
+
// dropped by a missing plugin.
|
|
1177
|
+
if (schema?.idempotency !== undefined && this.idempotencyRuntime === undefined) {
|
|
1178
|
+
throw new RouteConfigError(
|
|
1179
|
+
"INVALID_IDEMPOTENCY",
|
|
1180
|
+
"route declares idempotency but the idempotency plugin is not installed; add .use(idempotency())",
|
|
1181
|
+
)
|
|
1182
|
+
}
|
|
1183
|
+
const idempotent = this.idempotencyRuntime?.resolve(schema, authenticated, this.maxBodyBytes)
|
|
1184
|
+
// A ledgered route (capabilities declared + `.use(effectLedger())`) needs a per-request
|
|
1972
1185
|
// context to carry the ledger and a settle step to seal + sink it, so it too leaves the
|
|
1973
1186
|
// fused/contextless fast path. Resolved per route, at registration — like the capability guard.
|
|
1974
|
-
const ledgered: ResolvedEffectLedger | undefined =
|
|
1975
|
-
capabilities
|
|
1976
|
-
|
|
1977
|
-
|
|
1187
|
+
const ledgered: ResolvedEffectLedger | undefined = this.effectLedgerRuntime?.resolve(
|
|
1188
|
+
capabilities,
|
|
1189
|
+
method,
|
|
1190
|
+
path,
|
|
1191
|
+
)
|
|
1978
1192
|
const bare =
|
|
1193
|
+
schema?.params === undefined &&
|
|
1979
1194
|
schema?.body === undefined &&
|
|
1980
1195
|
schema?.query === undefined &&
|
|
1981
1196
|
idempotent === undefined &&
|
|
@@ -1997,6 +1212,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1997
1212
|
? "bare"
|
|
1998
1213
|
: schema?.body !== undefined &&
|
|
1999
1214
|
schema.query === undefined &&
|
|
1215
|
+
schema.params === undefined &&
|
|
2000
1216
|
this.derives.length === 0 &&
|
|
2001
1217
|
this.beforeHandleHooks.length === 0 &&
|
|
2002
1218
|
this.afterHandleHooks.length === 0 &&
|
|
@@ -2004,6 +1220,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2004
1220
|
? "body"
|
|
2005
1221
|
: schema?.body === undefined &&
|
|
2006
1222
|
schema?.query !== undefined &&
|
|
1223
|
+
schema.params === undefined &&
|
|
2007
1224
|
this.derives.length === 0 &&
|
|
2008
1225
|
this.beforeHandleHooks.length === 0 &&
|
|
2009
1226
|
this.afterHandleHooks.length === 0 &&
|
|
@@ -2038,8 +1255,20 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2038
1255
|
path,
|
|
2039
1256
|
schema,
|
|
2040
1257
|
...(capabilities.length > 0 ? { capabilities } : {}),
|
|
1258
|
+
...(schema?.family === true ? { family: true } : {}),
|
|
2041
1259
|
}
|
|
2042
1260
|
const routeAssurance: AssuranceDeclaration[] = [...this.activeAssurance, ...handlerAssurance]
|
|
1261
|
+
// Inline `schema.assurance`: the route DECLARES its enforcement evidence adjacent to the handler, so an
|
|
1262
|
+
// in-handler-guarded route satisfies a policy `require:` clause without a `withRouteAssurance` middleware
|
|
1263
|
+
// rewrite. Each id becomes route-scoped `declared` evidence (invalid ids fail closed at registration).
|
|
1264
|
+
for (const id of schema?.assurance ?? []) {
|
|
1265
|
+
if (!validEvidenceId(id)) {
|
|
1266
|
+
throw new Error(
|
|
1267
|
+
`route assurance: invalid evidence id ${JSON.stringify(id)} on ${method} ${path} (use lowercase dot/dash segments)`,
|
|
1268
|
+
)
|
|
1269
|
+
}
|
|
1270
|
+
routeAssurance.push(Object.freeze({ id, source: "declared", scope: "plugin" }))
|
|
1271
|
+
}
|
|
2043
1272
|
if (schema?.body !== undefined) {
|
|
2044
1273
|
routeAssurance.push(
|
|
2045
1274
|
Object.freeze({
|
|
@@ -2156,23 +1385,21 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2156
1385
|
runtime.maxBodyBytes,
|
|
2157
1386
|
)
|
|
2158
1387
|
let ledger: RequestLedger | undefined
|
|
2159
|
-
|
|
1388
|
+
// The runtime is always present when a route resolved a ledger (enforced at registration).
|
|
1389
|
+
const ledgerRuntime = runtime.effectLedgerRuntime
|
|
1390
|
+
if (hasLedger && ledgerRuntime !== undefined) {
|
|
2160
1391
|
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)
|
|
1392
|
+
ledger = ledgerRuntime.create(resolved)
|
|
1393
|
+
ledgerRuntime.attach(ctx, ledger)
|
|
2169
1394
|
}
|
|
2170
1395
|
let outcome = execute(runtime, entry, source, ctx, finalize, wrapResponse)
|
|
2171
|
-
if (ledger !== undefined) {
|
|
1396
|
+
if (ledger !== undefined && ledgerRuntime !== undefined) {
|
|
2172
1397
|
const active = ledger
|
|
2173
1398
|
const resolved = entry.ledgered as ResolvedEffectLedger
|
|
2174
1399
|
outcome = (outcome instanceof Promise ? outcome : Promise.resolve(outcome)).then((value) =>
|
|
2175
|
-
|
|
1400
|
+
ledgerRuntime.settle(active, resolved, value, (fields) =>
|
|
1401
|
+
runtime.logger.error("effect ledger sink failed", fields),
|
|
1402
|
+
),
|
|
2176
1403
|
)
|
|
2177
1404
|
}
|
|
2178
1405
|
return outcome
|
|
@@ -2180,82 +1407,58 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2180
1407
|
return Object.freeze({ run, fusedWeb })
|
|
2181
1408
|
}
|
|
2182
1409
|
|
|
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
|
-
const namespace = config.namespace
|
|
2220
|
-
if (namespace === undefined) {
|
|
2221
|
-
throw new RouteConfigError(
|
|
2222
|
-
"INVALID_IDEMPOTENCY",
|
|
2223
|
-
"idempotency namespace is required; use a principal resolver or an explicit shared scope",
|
|
2224
|
-
)
|
|
2225
|
-
}
|
|
2226
|
-
if (typeof namespace === "string" && !validIdempotencyNamespace(namespace)) {
|
|
2227
|
-
throw new RouteConfigError(
|
|
2228
|
-
"INVALID_IDEMPOTENCY",
|
|
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
|
-
})
|
|
1410
|
+
/** The idempotency lane's bridge back into the normal matched lanes, resolved to a concrete Response
|
|
1411
|
+
* (the lane buffers the body, then replays it through the route's real validation + handler). */
|
|
1412
|
+
private idempotencyRunLanes(
|
|
1413
|
+
buffered: RequestSource,
|
|
1414
|
+
platform: Platform | undefined,
|
|
1415
|
+
entry: RouteEntry,
|
|
1416
|
+
params: Record<string, string>,
|
|
1417
|
+
search: string | undefined,
|
|
1418
|
+
): Promise<Response> {
|
|
1419
|
+
return Promise.resolve(
|
|
1420
|
+
this.runMatchedLanes(
|
|
1421
|
+
buffered,
|
|
1422
|
+
platform,
|
|
1423
|
+
entry,
|
|
1424
|
+
params,
|
|
1425
|
+
search,
|
|
1426
|
+
toResponse,
|
|
1427
|
+
IDENTITY_RESPONSE,
|
|
1428
|
+
RESPONSE_TIMEOUT,
|
|
1429
|
+
false,
|
|
1430
|
+
),
|
|
1431
|
+
)
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
/** @internal Symbol-keyed install seam for the `idempotency()` plugin. Off the public typed surface. */
|
|
1435
|
+
[INSTALL_IDEMPOTENCY](runtime: IdempotencyRuntime): void {
|
|
1436
|
+
this.assertConfigurable("idempotency()")
|
|
1437
|
+
this.idempotencyRuntime = runtime
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
/** @internal Symbol-keyed install seam for the `mcp()` plugin. Off the public typed surface. */
|
|
1441
|
+
[INSTALL_MCP](runtime: McpRuntime): void {
|
|
1442
|
+
this.assertConfigurable("mcp()")
|
|
1443
|
+
this.mcpRuntime = runtime
|
|
2252
1444
|
}
|
|
2253
1445
|
|
|
2254
|
-
/**
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
this.
|
|
2258
|
-
|
|
1446
|
+
/** @internal Symbol-keyed install seam for the `nodeDirect()` plugin. Off the public typed surface. */
|
|
1447
|
+
[INSTALL_NODE_DIRECT](runtime: NodeOutcomeRuntime): void {
|
|
1448
|
+
this.assertConfigurable("nodeDirect()")
|
|
1449
|
+
this.nodeOutcomeRuntime = runtime
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
/** @internal Symbol-keyed install seam for the `streaming()` plugin. Off the public typed surface. */
|
|
1453
|
+
[INSTALL_SSE](runtime: SseRuntime): void {
|
|
1454
|
+
this.assertConfigurable("streaming()")
|
|
1455
|
+
this.sseRuntime = runtime
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
/** @internal Symbol-keyed install seam for the `websocket()` plugin. Off the public typed surface. */
|
|
1459
|
+
[INSTALL_WS](runtime: WsRuntime): void {
|
|
1460
|
+
this.assertConfigurable("websocket()")
|
|
1461
|
+
this.wsRuntime = runtime
|
|
2259
1462
|
}
|
|
2260
1463
|
|
|
2261
1464
|
/**
|
|
@@ -2287,6 +1490,17 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2287
1490
|
)
|
|
2288
1491
|
}
|
|
2289
1492
|
this.catalog.addBatch(source.catalog.entries().map((route) => this.bindFusedRuntime(route)))
|
|
1493
|
+
// Resolved idempotency/ledger route entries carry their own store/sink configuration, while the
|
|
1494
|
+
// runtime object supplies the generic execution machinery. Preserve a group's installed runtime
|
|
1495
|
+
// when the parent has none so merging cannot silently disable a safety lane. If the parent already
|
|
1496
|
+
// has a runtime, either implementation can execute every resolved entry because route-specific
|
|
1497
|
+
// options were pinned during registration.
|
|
1498
|
+
this.idempotencyRuntime ??= source.idempotencyRuntime
|
|
1499
|
+
this.effectLedgerRuntime ??= source.effectLedgerRuntime
|
|
1500
|
+
this.mcpRuntime ??= source.mcpRuntime
|
|
1501
|
+
this.nodeOutcomeRuntime ??= source.nodeOutcomeRuntime
|
|
1502
|
+
this.sseRuntime ??= source.sseRuntime
|
|
1503
|
+
this.wsRuntime ??= source.wsRuntime
|
|
2290
1504
|
this.onRequestHooks.push(...source.onRequestHooks)
|
|
2291
1505
|
this.onResponseHooks.push(...source.onResponseHooks)
|
|
2292
1506
|
this.onResponseFinalizedHooks.push(...source.onResponseFinalizedHooks)
|
|
@@ -2470,6 +1684,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2470
1684
|
resolveNodeSource(
|
|
2471
1685
|
source: RequestSource,
|
|
2472
1686
|
platform?: Platform<EnvOf<Ctx>>,
|
|
1687
|
+
suppliedRuntime?: NodeOutcomeRuntime,
|
|
2473
1688
|
): MaybePromise<NodeServeOutcome> {
|
|
2474
1689
|
// onResponse hooks transform a Response, and the capacity gate wraps the Web response path — both
|
|
2475
1690
|
// force the Web path here (the gated `fetchSource` admits/sheds/releases); wrap its result.
|
|
@@ -2486,12 +1701,19 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2486
1701
|
// May resolve **synchronously** for a compiled bare route + sync handler - the `@nifrajs/node`
|
|
2487
1702
|
// adapter `await`s the result, so it transparently handles either; the sync case allocates no promise
|
|
2488
1703
|
// at all on the Node hot path.
|
|
1704
|
+
const runtime = suppliedRuntime ?? this.nodeOutcomeRuntime
|
|
1705
|
+
if (runtime === undefined) {
|
|
1706
|
+
throw new FrameworkError(
|
|
1707
|
+
"NODE_DIRECT_RUNTIME_MISSING",
|
|
1708
|
+
"resolveNode() needs the Node-direct renderer. Normal @nifrajs/node serving installs it automatically; direct callers should add `.use(nodeDirect())` (from `@nifrajs/core/node-direct`).",
|
|
1709
|
+
)
|
|
1710
|
+
}
|
|
2489
1711
|
return this.dispatch<NodeServeOutcome>(
|
|
2490
1712
|
source,
|
|
2491
1713
|
platform,
|
|
2492
|
-
|
|
2493
|
-
|
|
2494
|
-
|
|
1714
|
+
runtime.toOutcome,
|
|
1715
|
+
runtime.fromResponse,
|
|
1716
|
+
runtime.timeout,
|
|
2495
1717
|
false,
|
|
2496
1718
|
)
|
|
2497
1719
|
}
|
|
@@ -2518,8 +1740,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2518
1740
|
const params = match.params === EMPTY_PARAMS ? match.params : decodeRouteParams(match.params)
|
|
2519
1741
|
if (params === null) return { kind: "reject", response: jsonError(400, "malformed_path") }
|
|
2520
1742
|
const handler = match.payload.handler
|
|
2521
|
-
// Non-null: wsRouteCount > 0 ⇒ ws() ran ⇒ the runtime
|
|
1743
|
+
// Non-null: wsRouteCount > 0 ⇒ ws() ran ⇒ `.use(websocket())` installed the runtime + registry.
|
|
2522
1744
|
const pubsub = this.topics as TopicRegistry
|
|
1745
|
+
const attach = (this.wsRuntime as WsRuntime).attach
|
|
2523
1746
|
// CSWSH guard, before any per-connection work or the user's upgrade(): reject a disallowed
|
|
2524
1747
|
// Origin with 403. Browsers don't CORS-protect WS handshakes but do send cookies, so this
|
|
2525
1748
|
// blocks cross-site authenticated sockets when the route opts in via `allowedOrigins`.
|
|
@@ -2538,7 +1761,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2538
1761
|
return { kind: "reject", response: jsonError(403, "forbidden_origin") }
|
|
2539
1762
|
}
|
|
2540
1763
|
if (handler.upgrade === undefined) {
|
|
2541
|
-
return { kind: "upgrade", handler, data: undefined, pubsub }
|
|
1764
|
+
return { kind: "upgrade", handler, data: undefined, pubsub, attach }
|
|
2542
1765
|
}
|
|
2543
1766
|
const upgradeSignal = getNeverAbortSignal()
|
|
2544
1767
|
const ctx = new RequestContext(
|
|
@@ -2553,7 +1776,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2553
1776
|
const settle = (value: unknown): WebSocketUpgradeOutcome =>
|
|
2554
1777
|
value instanceof Response
|
|
2555
1778
|
? { kind: "reject", response: value }
|
|
2556
|
-
: { kind: "upgrade", handler, data: value, pubsub }
|
|
1779
|
+
: { kind: "upgrade", handler, data: value, pubsub, attach }
|
|
2557
1780
|
try {
|
|
2558
1781
|
const result = handler.upgrade(ctx as unknown as WebSocketContext<EnvOf<Ctx>>)
|
|
2559
1782
|
return result instanceof Promise
|
|
@@ -2575,7 +1798,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2575
1798
|
server: BunUpgradeServer,
|
|
2576
1799
|
): MaybePromise<Response | undefined> {
|
|
2577
1800
|
const handle = (o: WebSocketUpgradeOutcome): MaybePromise<Response | undefined> => {
|
|
2578
|
-
if (o.kind === "pass")
|
|
1801
|
+
if (o.kind === "pass")
|
|
1802
|
+
return this.fetch(req, bunPeerPlatform(server, req) as Platform<EnvOf<Ctx>>)
|
|
2579
1803
|
if (o.kind === "reject") return o.response
|
|
2580
1804
|
return server.upgrade(req, { data: { handler: o.handler, data: o.data } })
|
|
2581
1805
|
? undefined
|
|
@@ -2592,6 +1816,16 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2592
1816
|
* produces the 503. The Web `fetch` and `resolveNode` are thin callers over this one routing +
|
|
2593
1817
|
* context + lifecycle implementation — no duplication across the trust boundary.
|
|
2594
1818
|
*/
|
|
1819
|
+
/** Apply the `clientIp` trust declaration to the adapter's raw socket peer, returning a platform
|
|
1820
|
+
* whose `clientIp` is the derived caller. Only called when a trust declaration is configured. */
|
|
1821
|
+
private deriveClientIp(
|
|
1822
|
+
source: RequestSource,
|
|
1823
|
+
platform: Platform | undefined,
|
|
1824
|
+
): Platform | undefined {
|
|
1825
|
+
const derived = resolveClientIp(platform?.clientIp, requestOf(source), this.clientIpTrust)
|
|
1826
|
+
return { ...platform, clientIp: derived }
|
|
1827
|
+
}
|
|
1828
|
+
|
|
2595
1829
|
private dispatch<T>(
|
|
2596
1830
|
source: RequestSource,
|
|
2597
1831
|
platform: Platform | undefined,
|
|
@@ -2602,12 +1836,17 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2602
1836
|
// `Response` (`T = Response` there by construction; the node path always passes false).
|
|
2603
1837
|
webFast: boolean,
|
|
2604
1838
|
): MaybePromise<T> {
|
|
1839
|
+
// Resolve the trust declaration into the platform's `clientIp` ONCE, here at the shared funnel, so
|
|
1840
|
+
// `c.clientIp` (and every hook/derive downstream) sees the derived caller. No config ⇒ the raw
|
|
1841
|
+
// socket peer the adapter supplied passes through untouched (a one-property no-op on the hot path).
|
|
1842
|
+
const resolved =
|
|
1843
|
+
this.clientIpTrust === undefined ? platform : this.deriveClientIp(source, platform)
|
|
2605
1844
|
// onRequest hooks may be async, so a hooked app takes the async path; with no hooks (the common
|
|
2606
1845
|
// case) routing stays synchronous, letting a bare route resolve with no lifecycle promise at all.
|
|
2607
1846
|
if (this.onRequestHooks.length === 0) {
|
|
2608
|
-
return this.routeAndRun(source,
|
|
1847
|
+
return this.routeAndRun(source, resolved, finalize, wrapResponse, onTimeout, webFast)
|
|
2609
1848
|
}
|
|
2610
|
-
return this.runWithOnRequest(source,
|
|
1849
|
+
return this.runWithOnRequest(source, resolved, finalize, wrapResponse, onTimeout, webFast)
|
|
2611
1850
|
}
|
|
2612
1851
|
|
|
2613
1852
|
/**
|
|
@@ -2752,8 +1991,22 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2752
1991
|
): MaybePromise<T> {
|
|
2753
1992
|
// An idempotency route runs its dedupe lane first; on a fresh key it delegates to the normal lanes
|
|
2754
1993
|
// (with the body buffered). All non-idempotent routes skip straight to the lanes — no added cost.
|
|
2755
|
-
|
|
2756
|
-
|
|
1994
|
+
// The runtime is always present when a route resolved idempotency (enforced at registration).
|
|
1995
|
+
if (entry.idempotent !== undefined && this.idempotencyRuntime !== undefined) {
|
|
1996
|
+
return this.idempotencyRuntime.run(
|
|
1997
|
+
entry.idempotent,
|
|
1998
|
+
requestOf(source),
|
|
1999
|
+
platform,
|
|
2000
|
+
entry,
|
|
2001
|
+
params,
|
|
2002
|
+
search,
|
|
2003
|
+
wrapResponse,
|
|
2004
|
+
{
|
|
2005
|
+
maxBodyBytes: this.maxBodyBytes,
|
|
2006
|
+
runLanes: (buffered, plat, ent, prm, srch) =>
|
|
2007
|
+
this.idempotencyRunLanes(buffered, plat, ent as RouteEntry, prm, srch),
|
|
2008
|
+
},
|
|
2009
|
+
)
|
|
2757
2010
|
}
|
|
2758
2011
|
return this.runMatchedLanes(
|
|
2759
2012
|
source,
|
|
@@ -2768,130 +2021,6 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2768
2021
|
)
|
|
2769
2022
|
}
|
|
2770
2023
|
|
|
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
2024
|
/** Supply request-specific deadline state to the route's precompiled execution plan. */
|
|
2896
2025
|
private runMatchedLanes<T>(
|
|
2897
2026
|
source: RequestSource,
|
|
@@ -2979,32 +2108,10 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2979
2108
|
return outcome
|
|
2980
2109
|
}
|
|
2981
2110
|
|
|
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
|
|
2111
|
+
/** @internal Symbol-keyed install seam for the `effectLedger()` plugin. Off the public typed surface. */
|
|
2112
|
+
[INSTALL_EFFECT_LEDGER](runtime: EffectLedgerRuntime): void {
|
|
2113
|
+
this.assertConfigurable("effectLedger()")
|
|
2114
|
+
this.effectLedgerRuntime = runtime
|
|
3008
2115
|
}
|
|
3009
2116
|
|
|
3010
2117
|
/** The narrowest bare route: a syntactic `() => ...` handler cannot observe the context argument, so
|
|
@@ -3650,6 +2757,15 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
3650
2757
|
wrapResponse: (response: Response) => T,
|
|
3651
2758
|
): Promise<T> {
|
|
3652
2759
|
try {
|
|
2760
|
+
if (entry.schema?.params !== undefined) {
|
|
2761
|
+
// Path params arrive as strings from routing; validate (and coerce) them at the boundary, before
|
|
2762
|
+
// the body/query, so a malformed `:id` is a 422 before any work runs. Raw `.validate` (no wrapper
|
|
2763
|
+
// alloc), same recovery-hook path as body/query.
|
|
2764
|
+
const validation = entry.schema.params["~standard"].validate(ctx.params)
|
|
2765
|
+
const result = validation instanceof Promise ? await validation : validation
|
|
2766
|
+
const paramsError = await this.applyLifecycleValidation(entry, result, ctx, "params")
|
|
2767
|
+
if (paramsError !== undefined) return wrapResponse(paramsError)
|
|
2768
|
+
}
|
|
3653
2769
|
if (entry.schema?.body !== undefined) {
|
|
3654
2770
|
const bodyError = await this.readAndValidateBody(source, entry, ctx)
|
|
3655
2771
|
if (bodyError !== undefined) return wrapResponse(bodyError)
|
|
@@ -3773,11 +2889,15 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
3773
2889
|
entry: RouteEntry,
|
|
3774
2890
|
result: StandardResult<unknown>,
|
|
3775
2891
|
ctx: RawContext,
|
|
3776
|
-
kind: "body" | "query",
|
|
2892
|
+
kind: "body" | "query" | "params",
|
|
3777
2893
|
): Promise<Response | undefined> {
|
|
2894
|
+
const assign = (value: unknown): void => {
|
|
2895
|
+
if (kind === "body") ctx.body = value
|
|
2896
|
+
else if (kind === "query") ctx.query = value
|
|
2897
|
+
else ctx.params = value as Record<string, string>
|
|
2898
|
+
}
|
|
3778
2899
|
if (result.issues === undefined) {
|
|
3779
|
-
|
|
3780
|
-
else ctx.query = result.value
|
|
2900
|
+
assign(result.value)
|
|
3781
2901
|
return undefined
|
|
3782
2902
|
}
|
|
3783
2903
|
const hook = entry.schema?.onValidationError ?? this.defaultOnValidationError
|
|
@@ -3786,12 +2906,16 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
3786
2906
|
const recovery = attempted instanceof Promise ? await attempted : attempted
|
|
3787
2907
|
if (recovery === undefined) return validationError(result.issues)
|
|
3788
2908
|
if (recovery instanceof Response) return recovery
|
|
3789
|
-
const schema =
|
|
2909
|
+
const schema =
|
|
2910
|
+
kind === "body"
|
|
2911
|
+
? entry.schema?.body
|
|
2912
|
+
: kind === "query"
|
|
2913
|
+
? entry.schema?.query
|
|
2914
|
+
: entry.schema?.params
|
|
3790
2915
|
const retried = schema!["~standard"].validate(recovery)
|
|
3791
2916
|
const settled = retried instanceof Promise ? await retried : retried
|
|
3792
2917
|
if (settled.issues !== undefined) return validationError(settled.issues)
|
|
3793
|
-
|
|
3794
|
-
else ctx.query = settled.value
|
|
2918
|
+
assign(settled.value)
|
|
3795
2919
|
return undefined
|
|
3796
2920
|
}
|
|
3797
2921
|
|
|
@@ -3867,7 +2991,16 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
3867
2991
|
* route selection/upgrade. Named wildcards also stay on the fallback until Bun exposes their raw
|
|
3868
2992
|
* capture semantics; static and `:param` routes take the native lane. */
|
|
3869
2993
|
private buildBunNativeRoutes(): BunNativeRoutes | undefined {
|
|
3870
|
-
|
|
2994
|
+
// A `clientIp` trust declaration must run the resolver in `dispatch`, which the fused native lane
|
|
2995
|
+
// bypasses — so an app that declares trust routes through the fetch lane (where `c.clientIp`
|
|
2996
|
+
// resolves) instead of Bun's native table. The allocation-free default keeps native fusion.
|
|
2997
|
+
if (
|
|
2998
|
+
this.onRequestHooks.length > 0 ||
|
|
2999
|
+
this.wsRouteCount > 0 ||
|
|
3000
|
+
this.clientIpTrust !== undefined
|
|
3001
|
+
) {
|
|
3002
|
+
return undefined
|
|
3003
|
+
}
|
|
3871
3004
|
|
|
3872
3005
|
const routes: BunNativeRoutes = Object.create(null) as BunNativeRoutes
|
|
3873
3006
|
const mayUseFusedNative =
|
|
@@ -3938,12 +3071,12 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
3938
3071
|
// `server` 2nd arg is how Bun exposes `upgrade`); otherwise the lean request-only fetch. The
|
|
3939
3072
|
// `websocket` handlers are one shared dispatcher — each connection's `ws.data.handler` is the
|
|
3940
3073
|
// 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.
|
|
3074
|
+
// With WS routes, the dispatcher comes from the installed `.use(websocket())` runtime — non-null
|
|
3075
|
+
// because wsRouteCount > 0 means ws() ran, and ws() requires the runtime at registration.
|
|
3943
3076
|
const wsHandlers =
|
|
3944
3077
|
this.wsRouteCount === 0
|
|
3945
3078
|
? undefined
|
|
3946
|
-
: (
|
|
3079
|
+
: (this.wsRuntime as WsRuntime).bunHandlers(this.topics as TopicRegistry)
|
|
3947
3080
|
const reusePort = options?.reusePort === true
|
|
3948
3081
|
// Spread rather than pass `hostname: undefined` - Bun treats an explicit undefined as a value
|
|
3949
3082
|
// on some option paths, and omitting is what selects its 0.0.0.0 default.
|
|
@@ -3955,7 +3088,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
3955
3088
|
reusePort,
|
|
3956
3089
|
...bind,
|
|
3957
3090
|
...(nativeRoutes === undefined ? {} : { routes: nativeRoutes }),
|
|
3958
|
-
fetch: (req: Request) =>
|
|
3091
|
+
fetch: (req: Request, server) =>
|
|
3092
|
+
this.fetch(req, bunPeerPlatform(server, req) as Platform<EnvOf<Ctx>>),
|
|
3959
3093
|
})
|
|
3960
3094
|
: Bun.serve<BunWsData>({
|
|
3961
3095
|
port,
|
|
@@ -4135,9 +3269,9 @@ export function toFetchHandler<Env = unknown>(
|
|
|
4135
3269
|
const pair = new Pair()
|
|
4136
3270
|
const server = pair[1]
|
|
4137
3271
|
server.accept()
|
|
4138
|
-
//
|
|
4139
|
-
//
|
|
4140
|
-
|
|
3272
|
+
// The outcome carries the installed runtime's `attach`, so `toFetchHandler` (a standalone
|
|
3273
|
+
// function, no access to the app's private runtime) wires the socket without a static WS import.
|
|
3274
|
+
outcome.attach(server, outcome.handler, outcome.data, {
|
|
4141
3275
|
openNow: true,
|
|
4142
3276
|
pubsub: outcome.pubsub,
|
|
4143
3277
|
})
|