@nifrajs/core 1.12.0 → 2.0.0

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