@nifrajs/core 1.13.0 → 2.1.0

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