@nifrajs/core 1.13.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (157) hide show
  1. package/README.md +10 -1
  2. package/dist/assurance.d.ts +11 -2
  3. package/dist/assurance.d.ts.map +1 -1
  4. package/dist/assurance.js +20 -0
  5. package/dist/assurance.js.map +1 -1
  6. package/dist/effect-ledger.d.ts +9 -0
  7. package/dist/effect-ledger.d.ts.map +1 -0
  8. package/dist/effect-ledger.js +8 -0
  9. package/dist/effect-ledger.js.map +1 -0
  10. package/dist/idempotency-plugin.d.ts +8 -0
  11. package/dist/idempotency-plugin.d.ts.map +1 -0
  12. package/dist/idempotency-plugin.js +8 -0
  13. package/dist/idempotency-plugin.js.map +1 -0
  14. package/dist/index.d.ts +6 -35
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +6 -31
  17. package/dist/index.js.map +1 -1
  18. package/dist/mcp.d.ts +7 -0
  19. package/dist/mcp.d.ts.map +1 -0
  20. package/dist/mcp.js +56 -0
  21. package/dist/mcp.js.map +1 -0
  22. package/dist/node-direct.d.ts +5 -0
  23. package/dist/node-direct.d.ts.map +1 -0
  24. package/dist/node-direct.js +27 -0
  25. package/dist/node-direct.js.map +1 -0
  26. package/dist/reflection.d.ts +5 -0
  27. package/dist/reflection.d.ts.map +1 -1
  28. package/dist/reflection.js +2 -0
  29. package/dist/reflection.js.map +1 -1
  30. package/dist/server/client-ip.d.ts +35 -0
  31. package/dist/server/client-ip.d.ts.map +1 -0
  32. package/dist/server/client-ip.js +42 -0
  33. package/dist/server/client-ip.js.map +1 -0
  34. package/dist/server/context.d.ts +47 -2
  35. package/dist/server/context.d.ts.map +1 -1
  36. package/dist/server/contract.d.ts +14 -4
  37. package/dist/server/contract.d.ts.map +1 -1
  38. package/dist/server/contract.js +8 -2
  39. package/dist/server/contract.js.map +1 -1
  40. package/dist/server/http.d.ts +14 -0
  41. package/dist/server/http.d.ts.map +1 -0
  42. package/dist/server/http.js +45 -0
  43. package/dist/server/http.js.map +1 -0
  44. package/dist/server/idempotency-lane.d.ts +44 -0
  45. package/dist/server/idempotency-lane.d.ts.map +1 -0
  46. package/dist/server/idempotency-lane.js +181 -0
  47. package/dist/server/idempotency-lane.js.map +1 -0
  48. package/dist/server/install.d.ts +21 -0
  49. package/dist/server/install.d.ts.map +1 -0
  50. package/dist/server/install.js +21 -0
  51. package/dist/server/install.js.map +1 -0
  52. package/dist/server/ledger-lane.d.ts +53 -0
  53. package/dist/server/ledger-lane.d.ts.map +1 -0
  54. package/dist/server/ledger-lane.js +82 -0
  55. package/dist/server/ledger-lane.js.map +1 -0
  56. package/dist/server/mcp-hook.d.ts +28 -0
  57. package/dist/server/mcp-hook.d.ts.map +1 -0
  58. package/dist/server/mcp-hook.js +2 -0
  59. package/dist/server/mcp-hook.js.map +1 -0
  60. package/dist/server/node-outcome-hook.d.ts +9 -0
  61. package/dist/server/node-outcome-hook.d.ts.map +1 -0
  62. package/dist/server/node-outcome-hook.js +2 -0
  63. package/dist/server/node-outcome-hook.js.map +1 -0
  64. package/dist/server/node-outcome.d.ts +37 -0
  65. package/dist/server/node-outcome.d.ts.map +1 -0
  66. package/dist/server/node-outcome.js +75 -0
  67. package/dist/server/node-outcome.js.map +1 -0
  68. package/dist/server/plugin.d.ts +78 -0
  69. package/dist/server/plugin.d.ts.map +1 -0
  70. package/dist/server/plugin.js +57 -0
  71. package/dist/server/plugin.js.map +1 -0
  72. package/dist/server/query.d.ts +18 -0
  73. package/dist/server/query.d.ts.map +1 -0
  74. package/dist/server/query.js +102 -0
  75. package/dist/server/query.js.map +1 -0
  76. package/dist/server/registry.d.ts +31 -6
  77. package/dist/server/registry.d.ts.map +1 -1
  78. package/dist/server/request-context.d.ts +51 -0
  79. package/dist/server/request-context.d.ts.map +1 -0
  80. package/dist/server/request-context.js +184 -0
  81. package/dist/server/request-context.js.map +1 -0
  82. package/dist/server/respond.d.ts +17 -0
  83. package/dist/server/respond.d.ts.map +1 -0
  84. package/dist/server/respond.js +81 -0
  85. package/dist/server/respond.js.map +1 -0
  86. package/dist/server/runtime-core.d.ts +36 -0
  87. package/dist/server/runtime-core.d.ts.map +1 -0
  88. package/dist/server/runtime-core.js +51 -0
  89. package/dist/server/runtime-core.js.map +1 -0
  90. package/dist/server/server-types.d.ts +203 -0
  91. package/dist/server/server-types.d.ts.map +1 -0
  92. package/dist/server/server-types.js +2 -0
  93. package/dist/server/server-types.js.map +1 -0
  94. package/dist/server/server.d.ts +107 -345
  95. package/dist/server/server.d.ts.map +1 -1
  96. package/dist/server/server.js +214 -831
  97. package/dist/server/server.js.map +1 -1
  98. package/dist/server/sse-hook.d.ts +10 -0
  99. package/dist/server/sse-hook.d.ts.map +1 -0
  100. package/dist/server/sse-hook.js +2 -0
  101. package/dist/server/sse-hook.js.map +1 -0
  102. package/dist/server/sse.d.ts +7 -23
  103. package/dist/server/sse.d.ts.map +1 -1
  104. package/dist/server/sse.js +18 -0
  105. package/dist/server/sse.js.map +1 -1
  106. package/dist/server/websocket.d.ts +17 -1
  107. package/dist/server/websocket.d.ts.map +1 -1
  108. package/dist/server/websocket.js.map +1 -1
  109. package/dist/server/ws-hook.d.ts +8 -11
  110. package/dist/server/ws-hook.d.ts.map +1 -1
  111. package/dist/server/ws-hook.js +7 -14
  112. package/dist/server/ws-hook.js.map +1 -1
  113. package/dist/server.d.ts +21 -0
  114. package/dist/server.d.ts.map +1 -0
  115. package/dist/server.js +16 -0
  116. package/dist/server.js.map +1 -0
  117. package/dist/ws.d.ts +16 -7
  118. package/dist/ws.d.ts.map +1 -1
  119. package/dist/ws.js +25 -10
  120. package/dist/ws.js.map +1 -1
  121. package/package.json +77 -6
  122. package/src/assurance.ts +36 -1
  123. package/src/effect-ledger.ts +9 -0
  124. package/src/idempotency-plugin.ts +7 -0
  125. package/src/index.ts +6 -133
  126. package/src/mcp.ts +65 -0
  127. package/src/node-direct.ts +37 -0
  128. package/src/reflection.ts +7 -0
  129. package/src/server/client-ip.ts +60 -0
  130. package/src/server/context.ts +50 -2
  131. package/src/server/contract.ts +26 -7
  132. package/src/server/http.ts +58 -0
  133. package/src/server/idempotency-lane.ts +287 -0
  134. package/src/server/install.ts +28 -0
  135. package/src/server/ledger-lane.ts +125 -0
  136. package/src/server/mcp-hook.ts +41 -0
  137. package/src/server/node-outcome-hook.ts +9 -0
  138. package/src/server/node-outcome.ts +112 -0
  139. package/src/server/plugin.ts +92 -0
  140. package/src/server/query.ts +119 -0
  141. package/src/server/registry.ts +36 -6
  142. package/src/server/request-context.ts +271 -0
  143. package/src/server/respond.ts +93 -0
  144. package/src/server/runtime-core.ts +74 -0
  145. package/src/server/server-types.ts +204 -0
  146. package/src/server/server.ts +402 -1314
  147. package/src/server/sse-hook.ts +14 -0
  148. package/src/server/sse.ts +26 -0
  149. package/src/server/websocket.ts +20 -0
  150. package/src/server/ws-hook.ts +8 -19
  151. package/src/server.ts +92 -0
  152. package/src/ws.ts +33 -10
  153. package/dist/invariants.d.ts +0 -70
  154. package/dist/invariants.d.ts.map +0 -1
  155. package/dist/invariants.js +0 -487
  156. package/dist/invariants.js.map +0 -1
  157. package/src/invariants.ts +0 -604
@@ -7,19 +7,6 @@ import {
7
7
  type RequestBudget,
8
8
  } from "../budget.ts"
9
9
  import { FrameworkError, RouteConfigError } from "../errors.ts"
10
- import {
11
- canonicalizeIdempotencyBody,
12
- computeIdempotencyFingerprint,
13
- DEFAULT_IDEMPOTENCY_HEADER,
14
- DEFAULT_IDEMPOTENCY_TTL_MS,
15
- IdempotencyResponseTooLargeError,
16
- type IdempotencyStore,
17
- MemoryIdempotencyStore,
18
- responseFromStored,
19
- serializeResponse,
20
- validIdempotencyKey,
21
- validIdempotencyNamespace,
22
- } from "../idempotency.ts"
23
10
  import {
24
11
  CAPABILITY_GUARD,
25
12
  type CapabilityUseEvent,
@@ -28,18 +15,12 @@ import {
28
15
  } from "../internal/capability-runtime.ts"
29
16
  import {
30
17
  type AssuranceDeclaration,
31
- type AssuranceEvidence,
32
18
  assuranceDeclarationsOf,
33
19
  assuranceEvidenceFor,
34
20
  NIFRA_ASSURANCE_IDS,
21
+ validEvidenceId,
35
22
  } from "../internal/route-assurance.ts"
36
- import {
37
- attachEffectLedger,
38
- createRequestLedger,
39
- DEFAULT_MAX_ENTRIES,
40
- type EffectLedgerOptions,
41
- type RequestLedger,
42
- } from "../ledger.ts"
23
+ import type { RequestLedger } from "../ledger.ts"
43
24
  import {
44
25
  type CompiledRoutePattern,
45
26
  compileRoutePattern,
@@ -52,12 +33,73 @@ import type {
52
33
  StandardResult,
53
34
  StandardSchemaV1,
54
35
  } from "../schema/standard.ts"
55
- import { drainCapped, parseContentLength, readBoundedBytes } from "./body.ts"
36
+ import { parseContentLength } from "./body.ts"
37
+ import { type ClientIpTrust, resolveClientIp } from "./client-ip.ts"
56
38
  import type { Context, Platform, ResponseControls, RouteSchema } from "./context.ts"
57
- import { type CookieOptions, parseCookies, serializeCookie } from "./cookies.ts"
39
+ import { jsonError, pathnameOf, urlPartsOf } from "./http.ts"
40
+ import type { NodeServeOutcome } from "./node-outcome.ts"
41
+ import type { NodeOutcomeRuntime } from "./node-outcome-hook.ts"
42
+ import {
43
+ isUrlEncodedForm,
44
+ type QueryValue,
45
+ queryObjectOf,
46
+ readBoundedForm,
47
+ searchOf,
48
+ } from "./query.ts"
49
+ import { RequestContext, readBoundedJsonSource } from "./request-context.ts"
50
+ import { fusedRespond, fusedRespondNoSet, toResponse } from "./respond.ts"
51
+ import {
52
+ CONTEXT_SEARCH,
53
+ CONTEXT_SET,
54
+ EMPTY_RESPONSE_CONTROLS,
55
+ getNeverAbortSignal,
56
+ getUnboundedRequestBudget,
57
+ type HandlerResult,
58
+ headerOf,
59
+ requestOf,
60
+ } from "./runtime-core.ts"
61
+
62
+ // NodeServeOutcome (the nifra<->node bridge render form) now lives in `./node-outcome.ts`; re-exported
63
+ // so existing importers keep resolving it from the server module.
64
+ export type { NodeServeOutcome }
65
+
66
+ import type { IdempotencyRuntime, ResolvedIdempotency } from "./idempotency-lane.ts"
67
+ import {
68
+ INSTALL_EFFECT_LEDGER,
69
+ INSTALL_IDEMPOTENCY,
70
+ INSTALL_MCP,
71
+ INSTALL_NODE_DIRECT,
72
+ INSTALL_SSE,
73
+ INSTALL_WS,
74
+ } from "./install.ts"
75
+ import type { EffectLedgerRuntime, ResolvedEffectLedger } from "./ledger-lane.ts"
58
76
  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"
77
+ import type { McpRuntime } from "./mcp-hook.ts"
78
+ import type { IdentityPlugin } from "./plugin.ts"
79
+ import type {
80
+ AddRoute,
81
+ EmptyRegistry,
82
+ OutputOf,
83
+ Registry,
84
+ RouteInfoFor,
85
+ WsRouteInfoFor,
86
+ } from "./registry.ts"
87
+ import type {
88
+ AdmissionController,
89
+ AdmissionDecision,
90
+ McpPromptDescriptor,
91
+ McpResourceDescriptor,
92
+ Middleware,
93
+ PromptArgument,
94
+ PromptMessage,
95
+ ResponseFinalization,
96
+ RouteDescriptor,
97
+ RunningServer,
98
+ ServerOptions,
99
+ ToolAnnotations,
100
+ } from "./server-types.ts"
101
+ import type { SSEInit, TypedSSEStream } from "./sse.ts"
102
+ import type { SseRuntime } from "./sse-hook.ts"
61
103
  import type {
62
104
  StandardWebSocket,
63
105
  TopicRegistry,
@@ -66,9 +108,9 @@ import type {
66
108
  WebSocketUpgradeOutcome,
67
109
  } from "./websocket.ts"
68
110
  import type { BunWsData } from "./ws-bun.ts"
69
- import { getWsRuntime, type WsRuntime } from "./ws-hook.ts"
111
+ import type { WsRuntime } from "./ws-hook.ts"
70
112
 
71
- type MaybePromise<T> = T | Promise<T>
113
+ export type MaybePromise<T> = T | Promise<T>
72
114
 
73
115
  /**
74
116
  * Internal request view. A real Web `Request` already satisfies this shape, so Web/edge runtimes pass
@@ -90,17 +132,6 @@ export interface RequestSource {
90
132
  readonly request?: Request
91
133
  }
92
134
 
93
- /** The concrete `Request` for a source — itself when a real `Request` was passed (the Web path), or the
94
- * lazily-built one (the Node adapter). A real `Request` IS a `RequestSource`, so no wrapper is allocated
95
- * on the Web hot path. */
96
- function requestOf(source: RequestSource): Request {
97
- return source.request ?? (source as unknown as Request)
98
- }
99
-
100
- function headerOf(source: RequestSource, name: string): string | null {
101
- return source.header?.(name) ?? source.headers.get(name)
102
- }
103
-
104
135
  /** The empty context extension. `NonNullable<unknown>` is `{}` without tripping noBannedTypes. */
105
136
  type EmptyContext = NonNullable<unknown>
106
137
 
@@ -111,42 +142,20 @@ type EmptyContext = NonNullable<unknown>
111
142
  */
112
143
  type EnvOf<Ctx> = Ctx extends { readonly env: infer E } ? E : unknown
113
144
 
114
- /** A handler returns a `Response` (used as-is) or any value (serialized to JSON). */
115
- type HandlerResult = Response | unknown
116
145
  type ContextlessHandler = () => MaybePromise<HandlerResult>
117
146
 
118
- const RESPONSE_RESULT = Symbol.for("nifra.response.result")
119
- const CONTEXT_SET = Symbol("nifra.context.set")
120
- const CONTEXT_SEARCH = Symbol("nifra.context.search")
121
147
  const functionToString = Function.prototype.toString
122
148
  const CONTEXTLESS_ARROW = /^(?:async\s*)?\(\s*\)\s*(?::[\s\S]*?)?=>/
123
149
 
124
- interface ResponseResult {
125
- readonly [RESPONSE_RESULT]: true
126
- toResponse(): Response
127
- toNodeBody?(): {
128
- readonly status: number
129
- readonly headers: Readonly<Record<string, string | readonly string[]>> | undefined
130
- readonly body: string | Uint8Array
131
- }
132
- }
133
-
134
- function isResponseResult(value: unknown): value is ResponseResult {
135
- return (
136
- typeof value === "object" &&
137
- value !== null &&
138
- (value as { readonly [RESPONSE_RESULT]?: unknown })[RESPONSE_RESULT] === true &&
139
- typeof (value as { readonly toResponse?: unknown }).toResponse === "function"
140
- )
141
- }
142
-
143
150
  /** Internal, path-erased runtime context. The typed `Context<Path, S>` is a structural view of this. */
144
- interface RawContext {
151
+ export interface RawContext {
145
152
  readonly req: Request
146
153
  readonly request: Request
147
154
  readonly json: (body: unknown, init?: ResponseInit | number) => Response
148
155
  readonly text: (body: string, init?: ResponseInit | number) => Response
149
- readonly params: Record<string, string>
156
+ // Writable: the lifecycle replaces it with the validated/coerced value when a `params` schema is
157
+ // declared (handlers still see it `readonly` via the public `Context` interface).
158
+ params: Record<string, string>
150
159
  query: unknown
151
160
  readonly cookies: Readonly<Record<string, string>>
152
161
  body: unknown
@@ -156,17 +165,12 @@ interface RawContext {
156
165
  readonly signal: AbortSignal
157
166
  readonly budget: RequestBudget
158
167
  readonly env: unknown
168
+ readonly clientIp: string | undefined
159
169
  readonly waitUntil: (promise: Promise<unknown>) => void
160
170
  readonly boundedBody: (maxBytes?: number) => Promise<Uint8Array>
161
171
  readonly boundedJson: <T = unknown>(maxBytes?: number) => Promise<T>
162
172
  }
163
173
 
164
- /** Off-edge `waitUntil`: run the background work fire-and-forget, never leaking an unhandled
165
- * rejection. Edge runtimes pass their own (Workers `ctx.waitUntil`) via the platform arg. */
166
- const fallbackWaitUntil = (promise: Promise<unknown>): void => {
167
- void promise.catch(() => {})
168
- }
169
-
170
174
  type InternalHandler = (ctx: RawContext) => MaybePromise<HandlerResult>
171
175
 
172
176
  /** Broad shape so the implementation signature is compatible with both typed overloads. */
@@ -183,27 +187,6 @@ type RawOnRequest = (req: Request, platform?: Platform) => MaybePromise<OnReques
183
187
  type RawOnResponse = (response: Response, req: Request) => MaybePromise<Response>
184
188
  type RawOnResponseFinalized = (outcome: ResponseFinalization, req: Request) => MaybePromise<void>
185
189
 
186
- /** Registration-resolved idempotency for a route: the config with its store + defaults pinned. */
187
- interface ResolvedIdempotency {
188
- readonly store: IdempotencyStore
189
- readonly ttlMs: number
190
- readonly headerName: string
191
- readonly namespace: NonNullable<RouteSchema["idempotency"]>["namespace"]
192
- readonly maxResponseBytes: number
193
- }
194
-
195
- /** Registration-resolved effect-ledger wiring for one capability-declaring route. */
196
- interface ResolvedEffectLedger {
197
- readonly sink: EffectLedgerOptions["sink"]
198
- readonly maxEntries: number
199
- readonly chain: boolean
200
- /** The registered route pattern — what the sealed ledger names (never the concrete URL). */
201
- readonly method: string
202
- readonly path: string
203
- /** The route's declared capability tokens, surfaced on the sealed ledger. */
204
- readonly declared: readonly string[]
205
- }
206
-
207
190
  type RouteExecutionRunner = <T, R extends Registry, Ctx>(
208
191
  runtime: Server<R, Ctx>,
209
192
  entry: RouteEntry,
@@ -340,9 +323,20 @@ interface WsEntry {
340
323
  readonly handler: WebSocketHandler
341
324
  }
342
325
 
343
- /** Structural view of the Bun `Server` `upgrade` the `fetch` 2nd arg exposes. */
326
+ /** Structural view of the Bun `Server` the `fetch` 2nd arg exposes (`upgrade` + the socket peer). */
344
327
  interface BunUpgradeServer {
345
328
  upgrade(request: Request, options?: { data?: BunWsData }): boolean
329
+ requestIP(request: Request): { readonly address: string } | null
330
+ }
331
+
332
+ /** The socket peer Bun observed, as a `Platform` for the request lifecycle (`undefined` if unknown).
333
+ * Typed structurally on `requestIP` alone so any Bun `Server` (WS or not) satisfies it. */
334
+ function bunPeerPlatform(
335
+ server: { requestIP(request: Request): { readonly address: string } | null },
336
+ req: Request,
337
+ ): Platform | undefined {
338
+ const address = server.requestIP(req)?.address
339
+ return address === undefined ? undefined : { clientIp: address }
346
340
  }
347
341
 
348
342
  type BunNativeHandler = (request: Request) => MaybePromise<Response>
@@ -353,12 +347,31 @@ type BunRequestWithParams = Request & { readonly params?: Record<string, string>
353
347
  const WS_PASS: WebSocketUpgradeOutcome = { kind: "pass" }
354
348
 
355
349
  /** `app.ws()` (and everything downstream of it) needs the runtime `@nifrajs/core/ws` registers. */
356
- function requireWsRuntime(): WsRuntime {
357
- const runtime = getWsRuntime()
350
+ function requireWsRuntime(runtime: WsRuntime | undefined): WsRuntime {
358
351
  if (runtime === undefined) {
359
352
  throw new FrameworkError(
360
353
  "WS_RUNTIME_MISSING",
361
- 'app.ws() needs the WebSocket runtime, which ships as a subpath so no-WebSocket apps stay lean. Add `import "@nifrajs/core/ws"` at your server entry.',
354
+ "app.ws() needs the WebSocket runtime, which ships as an opt-in plugin so no-WebSocket apps stay lean. Add `.use(websocket())` from `@nifrajs/core/ws` before declaring WS routes.",
355
+ )
356
+ }
357
+ return runtime
358
+ }
359
+
360
+ function requireSseRuntime(runtime: SseRuntime | undefined): SseRuntime {
361
+ if (runtime === undefined) {
362
+ throw new FrameworkError(
363
+ "SSE_RUNTIME_MISSING",
364
+ "app.sse() needs the streaming runtime, which ships as a subpath so non-SSE apps stay lean. Add `.use(streaming())` (from `@nifrajs/core/sse`) at your server setup.",
365
+ )
366
+ }
367
+ return runtime
368
+ }
369
+
370
+ function requireMcpRuntime(runtime: McpRuntime | undefined): McpRuntime {
371
+ if (runtime === undefined) {
372
+ throw new FrameworkError(
373
+ "MCP_RUNTIME_MISSING",
374
+ "MCP declarations ship as an opt-in runtime so ordinary HTTP apps stay lean. Add `.use(mcp())` (from `@nifrajs/core/mcp`) at your server setup.",
362
375
  )
363
376
  }
364
377
  return runtime
@@ -381,202 +394,21 @@ export type Handler<
381
394
  Ctx = EmptyContext,
382
395
  > = (ctx: Context<Path, S> & Ctx) => MaybePromise<ResponseOf<S>>
383
396
 
384
- /**
385
- * 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
397
+ // Route/option/descriptor + middleware-bundle types now live in `./server-types.ts`; re-exported so
398
+ // existing importers keep resolving them from the server module.
399
+ export type {
400
+ AdmissionController,
401
+ AdmissionDecision,
402
+ McpPromptDescriptor,
403
+ McpResourceDescriptor,
404
+ Middleware,
405
+ PromptArgument,
406
+ PromptMessage,
407
+ ResponseFinalization,
408
+ RouteDescriptor,
409
+ RunningServer,
410
+ ServerOptions,
411
+ ToolAnnotations,
580
412
  }
581
413
 
582
414
  // A plugin operates over arbitrary Server shapes; `any` here is the standard framework escape hatch
@@ -584,119 +416,19 @@ export interface ResponseFinalization {
584
416
  // biome-ignore lint/suspicious/noExplicitAny: plugins are generic over any Server's Registry/Context
585
417
  export type AnyServer = Server<any, any>
586
418
 
587
- /**
588
- * 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
419
+ // Plugin definers + their types now live in `./plugin.ts`; re-exported here so `.use()` callers and
420
+ // existing importers keep resolving them from the server module.
421
+ export {
422
+ defineIdentityPlugin,
423
+ definePlugin,
424
+ defineRouterPlugin,
425
+ type NifraPlugin,
426
+ } from "./plugin.ts"
427
+ export type { IdentityPlugin }
672
428
 
673
429
  const DEFAULT_MAX_BODY_BYTES = 1_000_000
674
430
  const DEFAULT_DRAIN_MS = 10_000
675
431
  const DRAIN_POLL_MS = 10
676
- const TEXT_DECODER = new TextDecoder()
677
- /**
678
- * Shared never-aborting signal for `ctx.signal` when no timeout is armed — created lazily and
679
- * cached. NOT a module-scope `new AbortController()`: edge runtimes (Cloudflare workerd) forbid
680
- * constructing one in global scope; the first request builds it inside the handler, then it's
681
- * reused at zero per-request cost.
682
- */
683
- let neverAbortSignal: AbortSignal | undefined
684
- let unboundedRequestBudget: RequestBudget | undefined
685
- const getNeverAbortSignal = (): AbortSignal => {
686
- neverAbortSignal ??= new AbortController().signal
687
- return neverAbortSignal
688
- }
689
- const getUnboundedRequestBudget = (): RequestBudget => {
690
- unboundedRequestBudget ??= createUnboundedRequestBudget(getNeverAbortSignal())
691
- return unboundedRequestBudget
692
- }
693
-
694
- function jsonError(status: number, error: string, headers?: Record<string, string>): Response {
695
- return Response.json(
696
- { ok: false, error },
697
- headers !== undefined ? { status, headers } : { status },
698
- )
699
- }
700
432
 
701
433
  /** Same-origin check for a WebSocket handshake (CSWSH default): the `Origin`'s host[:port] must equal the
702
434
  * request's own host (from `req.url`, which the runtime builds from the `Host` header). Scheme differs
@@ -717,157 +449,12 @@ function validationError(issues: ReadonlyArray<StandardIssue>): Response {
717
449
  return Response.json({ ok: false, error: "validation", issues: serialized }, { status: 422 })
718
450
  }
719
451
 
720
- 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
- }
452
+ // `jsonError`, `urlPartsOf`, `pathnameOf` moved to `./http.ts` (a dependency-free leaf shared with the
453
+ // opt-in request lanes); re-exported so existing importers keep resolving from here.
454
+ export { pathnameOf, urlPartsOf } from "./http.ts"
455
+ // Query-string + urlencoded-form parsing now lives in `./query.ts`; re-exported so existing
456
+ // importers keep resolving `searchOf`/`queryObjectOf`/`QueryValue` from here.
457
+ export { type QueryValue, queryObjectOf, searchOf }
871
458
 
872
459
  function hasReplacementParam(params: Record<string, string>): boolean {
873
460
  for (const key in params) {
@@ -878,48 +465,12 @@ function hasReplacementParam(params: Record<string, string>): boolean {
878
465
 
879
466
  /** `ctx.set` carrying the lazy backings (`_headers`, `_cookies`) so `toResponse` can skip allocating
880
467
  * anything when no handler touched `c.set.*`. Server-internal. */
881
- type CtxSet = ResponseControls & {
468
+ export type CtxSet = ResponseControls & {
882
469
  _headers?: Record<string, string>
883
470
  /** Accumulated `Set-Cookie` values — a list, since a `Record` would collapse multiple cookies. */
884
471
  _cookies?: string[]
885
472
  }
886
473
 
887
- class LazyResponseControls implements CtxSet {
888
- status?: number
889
- _headers?: Record<string, string>
890
- _cookies?: string[]
891
-
892
- get headers(): Record<string, string> {
893
- this._headers ??= {}
894
- return this._headers
895
- }
896
-
897
- cookie(name: string, value: string, options?: CookieOptions): void {
898
- // Secure-by-default: HttpOnly + Secure + SameSite=Lax + Path=/, overridable per call.
899
- const merged: CookieOptions = {
900
- httpOnly: true,
901
- secure: true,
902
- sameSite: "lax",
903
- path: "/",
904
- ...options,
905
- }
906
- this._cookies ??= []
907
- this._cookies.push(serializeCookie(name, value, merged))
908
- }
909
-
910
- deleteCookie(name: string, options?: Pick<CookieOptions, "path" | "domain">): void {
911
- // Expire immediately; default Path=/, and match the original path/domain or the browser keeps it.
912
- this._cookies ??= []
913
- this._cookies.push(
914
- serializeCookie(name, "", { path: "/", ...options, maxAge: 0, expires: EPOCH }),
915
- )
916
- }
917
- }
918
-
919
- // Finalization only reads `status`, `_headers`, and `_cookies`. The user-visible mutator methods
920
- // live on `LazyResponseControls`, created by the `c.set` getter only when user code touches it.
921
- const EMPTY_RESPONSE_CONTROLS = Object.freeze({}) as CtxSet
922
-
923
474
  function responseSet(ctx: RawContext): CtxSet {
924
475
  return ctx[CONTEXT_SET]() ?? EMPTY_RESPONSE_CONTROLS
925
476
  }
@@ -933,407 +484,9 @@ function isContextlessNoArgArrow(handler: (context: never) => unknown): boolean
933
484
  }
934
485
  }
935
486
 
936
- /** Coerce `c.json`/`c.text`'s second arg — a status number (the common case) or a full `ResponseInit`. */
937
- function statusInit(init?: ResponseInit | number): ResponseInit | undefined {
938
- return typeof init === "number" ? { status: init } : init
939
- }
940
-
941
- class RequestContext implements RawContext {
942
- // `declare` keeps TypeScript's class-field emit from first writing `undefined` to every slot; the
943
- // constructor initializes only the eager request state, while lazy fields remain absent until used.
944
- declare readonly params: Record<string, string>
945
- declare body: unknown
946
- private declare searchValue: string | undefined
947
- private declare signalValue: AbortSignal | undefined
948
- private declare budgetValue: RequestBudget | undefined
949
- private declare platformValue: Platform | undefined
950
-
951
- private declare setValue: CtxSet | undefined
952
- private declare queryValue: unknown
953
- private declare queryReady: boolean
954
- private declare cookiesValue: Readonly<Record<string, string>> | undefined
955
- private declare readonly source: RequestSource
956
- private declare readonly maxBodyBytes: number
957
-
958
- constructor(source: RequestSource, params: Record<string, string>, maxBodyBytes: number)
959
- constructor(
960
- source: RequestSource,
961
- params: Record<string, string>,
962
- search: string | undefined,
963
- signal: AbortSignal,
964
- budget: RequestBudget,
965
- platform: Platform | undefined,
966
- maxBodyBytes: number,
967
- )
968
- constructor(
969
- source: RequestSource,
970
- params: Record<string, string>,
971
- searchOrMaxBodyBytes: string | number | undefined,
972
- signal?: AbortSignal,
973
- budget?: RequestBudget,
974
- platform?: Platform,
975
- maxBodyBytes?: number,
976
- ) {
977
- this.source = source
978
- this.params = params
979
- if (typeof searchOrMaxBodyBytes === "number") {
980
- this.maxBodyBytes = searchOrMaxBodyBytes
981
- return
982
- }
983
- if (searchOrMaxBodyBytes !== undefined) this.searchValue = searchOrMaxBodyBytes
984
- this.signalValue = signal
985
- this.budgetValue = budget
986
- if (platform !== undefined) this.platformValue = platform
987
- this.maxBodyBytes = maxBodyBytes as number
988
- }
989
-
990
- static native(
991
- source: RequestSource,
992
- params: Record<string, string>,
993
- maxBodyBytes: number,
994
- ): RequestContext {
995
- return new RequestContext(source, params, maxBodyBytes)
996
- }
997
-
998
- [CONTEXT_SET](): CtxSet | undefined {
999
- return this.setValue
1000
- }
1001
-
1002
- get [CONTEXT_SEARCH](): string {
1003
- this.searchValue ??= searchOf(this.source.url)
1004
- return this.searchValue
1005
- }
1006
-
1007
- get set(): CtxSet {
1008
- this.setValue ??= new LazyResponseControls()
1009
- return this.setValue
1010
- }
1011
-
1012
- get signal(): AbortSignal {
1013
- return this.signalValue ?? getNeverAbortSignal()
1014
- }
1015
-
1016
- get budget(): RequestBudget {
1017
- return this.budgetValue ?? getUnboundedRequestBudget()
1018
- }
1019
-
1020
- get env(): unknown {
1021
- return this.platformValue?.env
1022
- }
1023
-
1024
- get waitUntil(): (promise: Promise<unknown>) => void {
1025
- return this.platformValue?.waitUntil ?? fallbackWaitUntil
1026
- }
1027
-
1028
- get req(): Request {
1029
- return requestOf(this.source)
1030
- }
1031
-
1032
- get request(): Request {
1033
- return requestOf(this.source)
1034
- }
1035
-
1036
- json(body: unknown, init?: ResponseInit | number): Response {
1037
- return Response.json(body, statusInit(init))
1038
- }
1039
-
1040
- text(body: string, init?: ResponseInit | number): Response {
1041
- const i = statusInit(init)
1042
- // Default to text/plain; if the caller passes their own headers, they own the content-type.
1043
- if (i?.headers !== undefined) return new Response(body, i)
1044
- return new Response(body, { ...i, headers: { "content-type": "text/plain; charset=utf-8" } })
1045
- }
1046
-
1047
- get query(): unknown {
1048
- if (!this.queryReady) {
1049
- this.queryValue = new URLSearchParams(this[CONTEXT_SEARCH])
1050
- this.queryReady = true
1051
- }
1052
- return this.queryValue
1053
- }
1054
-
1055
- set query(v: unknown) {
1056
- this.queryValue = v
1057
- this.queryReady = true
1058
- }
1059
-
1060
- get cookies(): Readonly<Record<string, string>> {
1061
- this.cookiesValue ??= parseCookies(headerOf(this.source, "cookie"))
1062
- return this.cookiesValue
1063
- }
1064
-
1065
- boundedBody(maxBytes?: number): Promise<Uint8Array> {
1066
- return readBoundedBodyOrThrow(this.source, this.maxBodyBytes, maxBytes)
1067
- }
1068
-
1069
- boundedJson<T = unknown>(maxBytes?: number): Promise<T> {
1070
- return readBoundedJsonBodyOrThrow<T>(this.source, this.maxBodyBytes, maxBytes)
1071
- }
1072
- }
1073
-
1074
- /** Backs `c.boundedBody`: bounded byte read that throws a flat 413/400 `Response` (caught by
1075
- * `runLifecycle` as control flow, like `throw redirect(...)`), so a handler can't ignore the cap.
1076
- * The byte-cap itself lives in `./body.ts` (shared with the schema path and `verifyWebhook`). */
1077
- async function readBoundedBodyOrThrow(
1078
- req: RequestSource,
1079
- maxBodyBytes: number,
1080
- maxBytes?: number,
1081
- ): Promise<Uint8Array> {
1082
- const r = await readBoundedBytes(req, maxBytes ?? maxBodyBytes)
1083
- if (r.ok) return r.bytes
1084
- throw r.status === 413
1085
- ? jsonError(413, "payload_too_large")
1086
- : jsonError(400, "invalid_content_length")
1087
- }
1088
-
1089
- /** Backs `c.boundedJson`: `readBoundedBodyOrThrow` + `JSON.parse`, throwing a flat 400 on bad JSON. */
1090
- async function readBoundedJsonBodyOrThrow<T>(
1091
- req: RequestSource,
1092
- maxBodyBytes: number,
1093
- maxBytes?: number,
1094
- ): Promise<T> {
1095
- const parsed = await readBoundedJsonSource(req, maxBytes ?? maxBodyBytes)
1096
- if (parsed instanceof Response) throw parsed
1097
- return parsed as T
1098
- }
1099
-
1100
- /**
1101
- * Read a JSON body with the same byte cap used by schema validation and `c.boundedJson`.
1102
- * Non-chunked, framed requests with an in-cap `Content-Length` use the runtime-native `req.json()`;
1103
- * chunked or length-less requests fall back to the streaming byte-cap guard.
1104
- */
1105
- async function readBoundedJsonSource(
1106
- req: RequestSource,
1107
- maxBytes: number,
1108
- ): Promise<unknown | Response> {
1109
- const declared = headerOf(req, "content-length")
1110
- if (declared !== null) {
1111
- // A present Content-Length must be a non-negative integer (HTTP grammar: `1*DIGIT`). A
1112
- // non-numeric / negative / fractional / exponential value (`Number()` would happily accept
1113
- // "abc"→NaN, "-5", "1.5", "1e3", "0x10") is malformed → 400, rather than silently falling
1114
- // through to the streaming guard — which is an UPPER-bound cap only, so a lying SMALLER length
1115
- // would otherwise be read in full. Real HTTP servers only hand us a valid framed length; this
1116
- // hardens hand-built Requests (tests, the in-process client) and crafted input.
1117
- const length = parseContentLength(declared)
1118
- if (length === undefined) return jsonError(400, "invalid_content_length")
1119
- if (length > maxBytes) return jsonError(413, "payload_too_large")
1120
- const chunked = headerOf(req, "transfer-encoding") !== null
1121
- if (!chunked) {
1122
- try {
1123
- return await req.json()
1124
- } catch {
1125
- return jsonError(400, "invalid_json")
1126
- }
1127
- }
1128
- }
1129
- const body = req.body
1130
- if (body === null) return jsonError(400, "invalid_json")
1131
- const drained = await drainCapped(body, maxBytes)
1132
- if (!drained.ok) return jsonError(413, "payload_too_large")
1133
- try {
1134
- return JSON.parse(TEXT_DECODER.decode(drained.bytes))
1135
- } catch {
1136
- return jsonError(400, "invalid_json")
1137
- }
1138
- }
1139
-
1140
- /** A fixed past instant for cookie deletion (`Expires`). A literal epoch — deterministic, unlike an
1141
- * argless `new Date()`. */
1142
- const EPOCH = new Date(0)
1143
-
1144
- /** Build the response headers init. The common path (no `c.set`) returns `undefined` so `Response`
1145
- * gets no `headers` at all. Cookies force a `Headers` object — multiple `Set-Cookie`s can't live in a
1146
- * `Record<string,string>` (the 2nd would overwrite the 1st), so they're `append`ed individually. */
1147
- function headersInit(set: CtxSet): Record<string, string> | Headers | undefined {
1148
- const cookies = set._cookies
1149
- if (cookies === undefined || cookies.length === 0) return set._headers
1150
- const headers = new Headers(set._headers)
1151
- for (const cookie of cookies) headers.append("set-cookie", cookie)
1152
- return headers
1153
- }
1154
-
1155
- // Keep the fast JSON respond path byte-identical to `Response.json` without probing it at module
1156
- // scope: workerd forbids `Response.json()` during startup. A shared `Headers` is safe to reuse
1157
- // across responses: the Response constructor copies `init.headers` into its own list.
1158
- const JSON_CT_HEADERS = new Headers({
1159
- "content-type": "application/json;charset=utf-8",
1160
- })
1161
- const JSON_INIT_200: ResponseInit = { status: 200, headers: JSON_CT_HEADERS }
1162
-
1163
- /** Fused-lane respond when `c.set` is untouched. Bun 1.3's native `Response.json` now beats the
1164
- * older hand-inlined stringify + Response construction on this lane while preserving the exact
1165
- * body/content-type contract; keep the generic fallback for non-JSON values. */
1166
- function fusedRespondNoSet(result: unknown): Response {
1167
- if (
1168
- result !== undefined &&
1169
- !(result instanceof Response) &&
1170
- typeof result === "object" &&
1171
- result !== null &&
1172
- !isResponseResult(result)
1173
- ) {
1174
- return Response.json(result)
1175
- }
1176
- return toResponse(result as HandlerResult, EMPTY_RESPONSE_CONTROLS)
1177
- }
1178
-
1179
- /** Fused-lane respond with a context: read `c.set` once; untouched (the common case) → the fast
1180
- * JSON respond; touched → the generic `toResponse` with those controls (statuses, headers, cookies). */
1181
- function fusedRespond(result: unknown, ctx: RawContext): Response {
1182
- const set = ctx[CONTEXT_SET]()
1183
- if (set === undefined) return fusedRespondNoSet(result)
1184
- return toResponse(result as HandlerResult, set)
1185
- }
1186
-
1187
- function toResponse(result: HandlerResult, set: CtxSet): Response {
1188
- if (isResponseResult(result)) {
1189
- return appendCookiesToResponse(result.toResponse(), set)
1190
- }
1191
- if (result instanceof Response) {
1192
- return appendCookiesToResponse(result, set)
1193
- }
1194
- const headers = headersInit(set)
1195
- const status = set.status ?? (result === undefined ? 204 : 200)
1196
- if (headers === undefined && result !== undefined) {
1197
- // Fast respond (profiled ≈ −50 ns/req on every plain-JSON return): `JSON.stringify` + a
1198
- // prebuilt init beats `Response.json`'s internal init handling. Output is byte-identical —
1199
- // same body bytes, same probed content-type. `undefined` from stringify (a function/symbol
1200
- // result) delegates to Response.json so its TypeError contract stays the single source.
1201
- const body = JSON.stringify(result) as string | undefined
1202
- if (body !== undefined) {
1203
- return new Response(
1204
- body,
1205
- status === 200 ? JSON_INIT_200 : { status, headers: JSON_CT_HEADERS },
1206
- )
1207
- }
1208
- }
1209
- const init: ResponseInit = headers === undefined ? { status } : { status, headers }
1210
- return result === undefined ? new Response(null, init) : Response.json(result, init)
1211
- }
1212
-
1213
- function appendCookiesToResponse(response: Response, set: CtxSet): Response {
1214
- // A handler may queue cookies (`c.set.cookie` — e.g. a session cookie) AND return its own Response
1215
- // (e.g. `redirect("/")` after login). Cookies accumulate additively, so append them to the
1216
- // returned Response — otherwise the canonical set-session-then-redirect pattern would silently drop
1217
- // the cookie. (Other `c.set` fields stay the returned Response's own concern.)
1218
- const cookies = set._cookies
1219
- if (cookies !== undefined && cookies.length > 0) {
1220
- for (const cookie of cookies) response.headers.append("set-cookie", cookie)
1221
- }
1222
- return response
1223
- }
1224
-
1225
- /**
1226
- * What {@link Server.resolveNode} returns: either a plain-data render the `@nifrajs/node` adapter writes
1227
- * to the socket directly (`kind: "json"` — status + headers + cookies + a pre-stringified body, **no**
1228
- * undici `Response` built or drained), a marked buffered response body (`kind: "body"` — e.g.
1229
- * @nifrajs/web's non-deferred SSR HTML), or a `Response` (`kind: "response"`) for everything else
1230
- * (redirects, 404/405/errors, unmarked or streaming bodies). Internal to the nifra↔node bridge.
1231
- */
1232
- export type NodeServeOutcome =
1233
- | { readonly kind: "response"; readonly response: Response }
1234
- | {
1235
- readonly kind: "json"
1236
- readonly status: number
1237
- /** `c.set.headers` backing, or `undefined` when the handler never set a header. */
1238
- readonly headers: Readonly<Record<string, string>> | undefined
1239
- /** Queued `Set-Cookie` lines, or `undefined`; the adapter emits one header line each. */
1240
- readonly cookies: readonly string[] | undefined
1241
- /** The JSON body already stringified, or `null` for an empty (204) response. */
1242
- readonly body: string | null
1243
- }
1244
- | {
1245
- readonly kind: "body"
1246
- readonly status: number
1247
- readonly headers: Readonly<Record<string, string | readonly string[]>> | undefined
1248
- readonly body: string | Uint8Array
1249
- }
1250
-
1251
- /**
1252
- * `finalize` for the node-direct path — mirror of {@link toResponse} that skips the `Response` build:
1253
- * a plain value becomes pre-stringified JSON primitives (the adapter `JSON.stringify`s once, here, not
1254
- * via `Response.json` + a body drain); a handler-returned `Response` is wrapped as-is, with queued
1255
- * cookies appended exactly as `toResponse` does (so the set-cookie-then-`redirect()` pattern still
1256
- * works on Node).
1257
- */
1258
- function toNodeOutcome(result: HandlerResult, set: CtxSet): NodeServeOutcome {
1259
- if (isResponseResult(result)) {
1260
- const body = result.toNodeBody?.()
1261
- if (body !== undefined) {
1262
- return {
1263
- kind: "body",
1264
- status: body.status,
1265
- headers: appendCookiesToNodeHeaders(body.headers, set._cookies),
1266
- body: body.body,
1267
- }
1268
- }
1269
- return nodeOutcomeFromResponse(appendCookiesToResponse(result.toResponse(), set))
1270
- }
1271
- if (result instanceof Response) {
1272
- return nodeOutcomeFromResponse(appendCookiesToResponse(result, set))
1273
- }
1274
- const status = set.status ?? (result === undefined ? 204 : 200)
1275
- return {
1276
- kind: "json",
1277
- status,
1278
- headers: set._headers,
1279
- cookies: set._cookies,
1280
- body: result === undefined ? null : JSON.stringify(result),
1281
- }
1282
- }
1283
-
1284
487
  // Stable module-level finalizers so `fetch`/`resolveNode` allocate no per-request closures.
1285
488
  const IDENTITY_RESPONSE = (response: Response): Response => response
1286
489
  const RESPONSE_TIMEOUT = (): Response => jsonError(503, "request_timeout")
1287
- const NODE_RESPONSE_BODY = Symbol.for("nifra.response.body")
1288
- const NODE_FROM_RESPONSE = (response: Response): NodeServeOutcome =>
1289
- nodeOutcomeFromResponse(response)
1290
- const NODE_TIMEOUT = (): NodeServeOutcome => ({
1291
- kind: "response",
1292
- response: jsonError(503, "request_timeout"),
1293
- })
1294
-
1295
- function nodeOutcomeFromResponse(response: Response): NodeServeOutcome {
1296
- const body = nodeResponseBody(response)
1297
- return body === undefined
1298
- ? { kind: "response", response }
1299
- : { kind: "body", status: response.status, headers: responseHeadersForNode(response), body }
1300
- }
1301
-
1302
- function nodeResponseBody(response: Response): string | Uint8Array | undefined {
1303
- if (response.bodyUsed) return undefined
1304
- const body = (response as { readonly [NODE_RESPONSE_BODY]?: unknown })[NODE_RESPONSE_BODY]
1305
- return typeof body === "string" || body instanceof Uint8Array ? body : undefined
1306
- }
1307
-
1308
- function responseHeadersForNode(
1309
- response: Response,
1310
- ): Readonly<Record<string, string | readonly string[]>> | undefined {
1311
- let headers: Record<string, string | readonly string[]> | undefined
1312
- response.headers.forEach((value, key) => {
1313
- headers ??= {}
1314
- headers[key] = value
1315
- })
1316
- const setCookies = response.headers.getSetCookie?.()
1317
- if (setCookies !== undefined && setCookies.length > 0) {
1318
- headers ??= {}
1319
- headers["set-cookie"] = setCookies
1320
- }
1321
- return headers
1322
- }
1323
-
1324
- function appendCookiesToNodeHeaders(
1325
- headers: Readonly<Record<string, string | readonly string[]>> | undefined,
1326
- cookies: readonly string[] | undefined,
1327
- ): Readonly<Record<string, string | readonly string[]>> | undefined {
1328
- if (cookies === undefined || cookies.length === 0) return headers
1329
- const out: Record<string, string | readonly string[]> =
1330
- headers === undefined ? {} : { ...headers }
1331
- const existing = out["set-cookie"]
1332
- const setCookies =
1333
- existing === undefined ? [] : typeof existing === "string" ? [existing] : [...existing]
1334
- out["set-cookie"] = [...setCookies, ...cookies]
1335
- return out
1336
- }
1337
490
 
1338
491
  /**
1339
492
  * The inline server. Routes are chainable and fully type-inferred. `derive`/
@@ -1355,6 +508,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1355
508
  private readonly maxBodyBytes: number
1356
509
  private readonly wsMaxPayloadBytes: number
1357
510
  private readonly requestTimeoutMs: number
511
+ /** Opt-in caller-IP trust declaration; `undefined` = socket peer only, no forwarded header believed. */
512
+ private readonly clientIpTrust: ClientIpTrust | undefined
1358
513
  private readonly acceptInboundDeadlines: boolean
1359
514
  private readonly maxInboundDeadlineMs: number
1360
515
  private readonly deadlineAdmissionOptions: DeadlineAdmissionOptions
@@ -1362,13 +517,20 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1362
517
  /** Capacity-admission gate; `undefined` = off (the request path pays nothing). */
1363
518
  private readonly capacityGate: AdmissionController | undefined
1364
519
  private readonly onCapabilityUse: ((event: CapabilityUseEvent) => void) | undefined
1365
- /** 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
520
+ /** The installed effect-ledger runtime (owns the sink + per-route resolution + settle), or
521
+ * `undefined` when the effect-ledger plugin is not installed. */
522
+ private effectLedgerRuntime: EffectLedgerRuntime | undefined
523
+ /** The installed idempotency runtime (owns the app-wide default store + the dedupe lane), or
524
+ * `undefined` when the idempotency plugin is not installed. */
525
+ private idempotencyRuntime: IdempotencyRuntime | undefined
526
+ /** Installed opt-in runtime for `.tool()`/`.resource()`/`.prompt()`; `undefined` until `.use(mcp())`. */
527
+ private mcpRuntime: McpRuntime | undefined
528
+ /** Installed Node-direct renderer for direct `resolveNode()` callers; `undefined` until `.use(nodeDirect())`. */
529
+ private nodeOutcomeRuntime: NodeOutcomeRuntime | undefined
530
+ /** Installed streaming runtime for `.sse()` routes; `undefined` until `.use(streaming())`. */
531
+ private sseRuntime: SseRuntime | undefined
532
+ /** Installed WebSocket runtime for `.ws()` routes; `undefined` until `.use(websocket())`. */
533
+ private wsRuntime: WsRuntime | undefined
1372
534
  private readonly logger: Logger
1373
535
  /** App-wide validation-error fallback; a route's own `schema.onValidationError` takes precedence. */
1374
536
  private readonly defaultOnValidationError?: RouteSchema["onValidationError"]
@@ -1401,6 +563,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1401
563
  this.maxBodyBytes = options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES
1402
564
  this.wsMaxPayloadBytes = options.wsMaxPayloadBytes ?? this.maxBodyBytes
1403
565
  this.requestTimeoutMs = options.requestTimeoutMs ?? 0
566
+ this.clientIpTrust = options.clientIp
1404
567
  this.acceptInboundDeadlines = options.acceptInboundDeadlines ?? false
1405
568
  this.maxInboundDeadlineMs = options.maxInboundDeadlineMs ?? 30_000
1406
569
  this.deadlineAdmissionOptions = Object.freeze({
@@ -1416,25 +579,12 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1416
579
  this.gracefulSignals = options.gracefulSignals ?? false
1417
580
  this.capacityGate = options.admission
1418
581
  this.onCapabilityUse = options.onCapabilityUse
1419
- 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
582
+ // The effect-ledger runtime is installed by `.use(effectLedger())`; a bare app never imports it, so
583
+ // the ledger machinery tree-shakes out. Capability-declaring routes simply carry no ledger without it.
584
+ this.effectLedgerRuntime = undefined
585
+ // The idempotency runtime is installed by `.use(idempotency())`; a bare app never imports it, so the
586
+ // dedupe machinery tree-shakes out. A route that declares idempotency without it is a build error.
587
+ this.idempotencyRuntime = undefined
1438
588
  this.logger = options.logger ?? jsonLogger()
1439
589
  this.defaultOnValidationError = options.onValidationError
1440
590
  this.bunServer = undefined
@@ -1629,7 +779,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1629
779
  * JSON-serialized into the SSE `data:` field. The typed client sees the marker and grows a
1630
780
  * `.subscribe(onEvent)` for the route with the same payload type — end-to-end typed streaming.
1631
781
  *
1632
- * const app = server().sse("/feed", { sse: t.object({ id: t.integer(), title: t.string() }) },
782
+ * import { streaming } from "@nifrajs/core/sse" // .use(streaming()) enables .sse()
783
+ * const app = server().use(streaming()).sse("/feed", { sse: t.object({ id: t.integer(), title: t.string() }) },
1633
784
  * async (c, stream) => {
1634
785
  * stream.send({ id: 1, title: "hello" }) // typed
1635
786
  * await waitForDisconnect(stream.signal)
@@ -1652,7 +803,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1652
803
  init?: SSEInit,
1653
804
  ): Server<AddRoute<R, "GET", Path, RouteInfoFor<Path, S, Response>>, Ctx> {
1654
805
  const handler = (context: Context<Path, S> & Ctx): Response =>
1655
- sse(context, (stream) => run(context, typedSSEStream(stream)), init)
806
+ requireSseRuntime(this.sseRuntime).response(context, (stream) => run(context, stream), init)
1656
807
  return this.route("GET", path, schema, handler as unknown as ErasedHandler) as Server<
1657
808
  AddRoute<R, "GET", Path, RouteInfoFor<Path, S, Response>>,
1658
809
  Ctx
@@ -1727,6 +878,19 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1727
878
  return this.route("DELETE", path, schemaOrHandler, handler)
1728
879
  }
1729
880
 
881
+ /**
882
+ * Declare an **MCP tool** an agent can call (via `nifra mcp`, or a mounted MCP endpoint): a typed
883
+ * `POST /_nifra/tool/<name>` route whose `input`/`output` schemas contract the call and surface in
884
+ * `tools/list`. Requires `.use(mcp())` - without it, `.tool()` is a registration error, so an
885
+ * ordinary HTTP app never bundles the MCP wiring. Siblings: {@link resource}, {@link prompt}.
886
+ *
887
+ * import { mcp } from "@nifrajs/core/mcp"
888
+ * const app = server().use(mcp()).tool(
889
+ * "search",
890
+ * { description: "Search posts", input: t.object({ q: t.string() }) },
891
+ * ({ q }) => findPosts(q),
892
+ * )
893
+ */
1730
894
  tool<
1731
895
  Name extends string,
1732
896
  S extends {
@@ -1768,24 +932,17 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1768
932
  },
1769
933
  handler: (input: unknown, ctx: Context & Ctx) => unknown,
1770
934
  ): Server<Registry, Ctx> {
1771
- const 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)
935
+ const plan = requireMcpRuntime(this.mcpRuntime).tool(
936
+ name,
937
+ config,
938
+ handler as (input: unknown, context: Context) => unknown,
939
+ )
940
+ this.register("POST", plan.path, plan.schema, plan.run as (context: never) => unknown)
1780
941
  // Tag the just-registered descriptor as an MCP tool. `tool` is readonly on RouteDescriptor (an
1781
942
  // introspection field), so write it through a narrow mutable view — not `any`.
1782
943
  const lastRoute = this.catalog.lastDescriptor()
1783
944
  if (lastRoute) {
1784
- ;(lastRoute as { tool?: RouteDescriptor["tool"] }).tool = {
1785
- name,
1786
- description: config.description,
1787
- ...(config.annotations !== undefined ? { annotations: config.annotations } : {}),
1788
- }
945
+ ;(lastRoute as { tool?: RouteDescriptor["tool"] }).tool = plan.descriptor
1789
946
  }
1790
947
  return this as unknown as Server<Registry, Ctx>
1791
948
  }
@@ -1802,13 +959,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1802
959
  read: McpResourceDescriptor["read"],
1803
960
  ): Server<R, Ctx> {
1804
961
  this.assertConfigurable("resource()")
1805
- this.mcpResourceList.push({
1806
- uri,
1807
- name: config.name,
1808
- ...(config.description !== undefined ? { description: config.description } : {}),
1809
- ...(config.mimeType !== undefined ? { mimeType: config.mimeType } : {}),
1810
- read,
1811
- })
962
+ this.mcpResourceList.push(requireMcpRuntime(this.mcpRuntime).resource(uri, config, read))
1812
963
  return this
1813
964
  }
1814
965
 
@@ -1822,12 +973,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1822
973
  handler: McpPromptDescriptor["handler"],
1823
974
  ): Server<R, Ctx> {
1824
975
  this.assertConfigurable("prompt()")
1825
- this.mcpPromptList.push({
1826
- name,
1827
- description: config.description,
1828
- ...(config.arguments !== undefined ? { arguments: config.arguments } : {}),
1829
- handler,
1830
- })
976
+ this.mcpPromptList.push(requireMcpRuntime(this.mcpRuntime).prompt(name, config, handler))
1831
977
  return this
1832
978
  }
1833
979
 
@@ -1848,16 +994,29 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1848
994
  * adapter (`listen()`, `@nifrajs/node`, `@nifrajs/deno`, `toFetchHandler`) — not by bare `app.fetch`, which
1849
995
  * has no socket (a WS path through `app.fetch` is a normal HTTP response).
1850
996
  *
997
+ * The route also enters the type-level registry (under the pseudo-method `"WS"`), so the typed
998
+ * client grows a `.ws()` handle for it: `messageSchema` types what the client may `send`,
999
+ * `sendSchema` types the frames it receives. Passing explicit type arguments (`ws<MyData>(…)`)
1000
+ * defeats path-literal inference and skips the registry entry - the route still serves, it is just
1001
+ * invisible to `client<App>`; prefer typing `data` via `upgrade()`'s return.
1002
+ *
1851
1003
  * app.ws("/chat", { open: (ws) => ws.send("hi"), message: (ws, data) => ws.send(data) })
1852
1004
  */
1853
- ws<Data = unknown, Schema extends StandardSchemaV1 | undefined = undefined>(
1854
- path: string,
1855
- handler: WebSocketHandler<Data, EnvOf<Ctx>, Schema>,
1856
- ): this {
1005
+ ws<
1006
+ Data = unknown,
1007
+ Schema extends StandardSchemaV1 | undefined = undefined,
1008
+ Send extends StandardSchemaV1 | undefined = undefined,
1009
+ Path extends string = string,
1010
+ >(
1011
+ path: Path,
1012
+ handler: WebSocketHandler<Data, EnvOf<Ctx>, Schema, Send>,
1013
+ ): string extends Path
1014
+ ? Server<R, Ctx>
1015
+ : Server<AddRoute<R, "WS", Path, WsRouteInfoFor<Path, Schema, Send>>, Ctx> {
1857
1016
  this.assertConfigurable("ws()")
1858
1017
  // Boot-time guard: the WS runtime is a subpath (`@nifrajs/core/ws`) so no-WebSocket apps don't
1859
1018
  // bundle it. Registration is the loud, early failure point — never the first connection.
1860
- const runtime = requireWsRuntime()
1019
+ const runtime = requireWsRuntime(this.wsRuntime)
1861
1020
  this.topics ??= runtime.createTopics()
1862
1021
  // A `messageSchema` wraps `message` with validation once, here — every adapter then dispatches
1863
1022
  // already-validated, typed messages (Bun/Deno/Node/Workers) with no per-adapter code.
@@ -1865,7 +1024,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1865
1024
  handler: runtime.wrapHandler(handler as WebSocketHandler),
1866
1025
  })
1867
1026
  this.wsRouteCount += 1
1868
- return this
1027
+ return this as never
1869
1028
  }
1870
1029
 
1871
1030
  /**
@@ -1966,16 +1125,26 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1966
1125
  const hasDecorations = Reflect.ownKeys(routeDecorations).length > 0
1967
1126
  // An idempotency route runs a dedupe lane that must buffer the body and capture the response, so it
1968
1127
  // never takes the fused/native fast path — force it onto the portable matched lane (which routes
1969
- // through `fetchMatched`, where the dedupe wrapper lives).
1970
- const idempotent = this.resolveIdempotency(schema, authenticated)
1971
- // A ledgered route (capabilities declared + `server({ effectLedger })`) needs a per-request
1128
+ // through `fetchMatched`, where the dedupe wrapper lives). Fail closed: a route may not declare
1129
+ // idempotency unless the idempotency runtime is installed, so the safety gate can never be silently
1130
+ // dropped by a missing plugin.
1131
+ if (schema?.idempotency !== undefined && this.idempotencyRuntime === undefined) {
1132
+ throw new RouteConfigError(
1133
+ "INVALID_IDEMPOTENCY",
1134
+ "route declares idempotency but the idempotency plugin is not installed; add .use(idempotency())",
1135
+ )
1136
+ }
1137
+ const idempotent = this.idempotencyRuntime?.resolve(schema, authenticated, this.maxBodyBytes)
1138
+ // A ledgered route (capabilities declared + `.use(effectLedger())`) needs a per-request
1972
1139
  // context to carry the ledger and a settle step to seal + sink it, so it too leaves the
1973
1140
  // fused/contextless fast path. Resolved per route, at registration — like the capability guard.
1974
- const ledgered: ResolvedEffectLedger | undefined =
1975
- capabilities.length > 0 && this.effectLedger !== undefined
1976
- ? Object.freeze({ ...this.effectLedger, method, path, declared: capabilities })
1977
- : undefined
1141
+ const ledgered: ResolvedEffectLedger | undefined = this.effectLedgerRuntime?.resolve(
1142
+ capabilities,
1143
+ method,
1144
+ path,
1145
+ )
1978
1146
  const bare =
1147
+ schema?.params === undefined &&
1979
1148
  schema?.body === undefined &&
1980
1149
  schema?.query === undefined &&
1981
1150
  idempotent === undefined &&
@@ -1997,6 +1166,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1997
1166
  ? "bare"
1998
1167
  : schema?.body !== undefined &&
1999
1168
  schema.query === undefined &&
1169
+ schema.params === undefined &&
2000
1170
  this.derives.length === 0 &&
2001
1171
  this.beforeHandleHooks.length === 0 &&
2002
1172
  this.afterHandleHooks.length === 0 &&
@@ -2004,6 +1174,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2004
1174
  ? "body"
2005
1175
  : schema?.body === undefined &&
2006
1176
  schema?.query !== undefined &&
1177
+ schema.params === undefined &&
2007
1178
  this.derives.length === 0 &&
2008
1179
  this.beforeHandleHooks.length === 0 &&
2009
1180
  this.afterHandleHooks.length === 0 &&
@@ -2038,8 +1209,20 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2038
1209
  path,
2039
1210
  schema,
2040
1211
  ...(capabilities.length > 0 ? { capabilities } : {}),
1212
+ ...(schema?.family === true ? { family: true } : {}),
2041
1213
  }
2042
1214
  const routeAssurance: AssuranceDeclaration[] = [...this.activeAssurance, ...handlerAssurance]
1215
+ // Inline `schema.assurance`: the route DECLARES its enforcement evidence adjacent to the handler, so an
1216
+ // in-handler-guarded route satisfies a policy `require:` clause without a `withRouteAssurance` middleware
1217
+ // rewrite. Each id becomes route-scoped `declared` evidence (invalid ids fail closed at registration).
1218
+ for (const id of schema?.assurance ?? []) {
1219
+ if (!validEvidenceId(id)) {
1220
+ throw new Error(
1221
+ `route assurance: invalid evidence id ${JSON.stringify(id)} on ${method} ${path} (use lowercase dot/dash segments)`,
1222
+ )
1223
+ }
1224
+ routeAssurance.push(Object.freeze({ id, source: "declared", scope: "plugin" }))
1225
+ }
2043
1226
  if (schema?.body !== undefined) {
2044
1227
  routeAssurance.push(
2045
1228
  Object.freeze({
@@ -2156,23 +1339,21 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2156
1339
  runtime.maxBodyBytes,
2157
1340
  )
2158
1341
  let ledger: RequestLedger | undefined
2159
- if (hasLedger) {
1342
+ // The runtime is always present when a route resolved a ledger (enforced at registration).
1343
+ const ledgerRuntime = runtime.effectLedgerRuntime
1344
+ if (hasLedger && ledgerRuntime !== undefined) {
2160
1345
  const resolved = entry.ledgered as ResolvedEffectLedger
2161
- ledger = 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)
1346
+ ledger = ledgerRuntime.create(resolved)
1347
+ ledgerRuntime.attach(ctx, ledger)
2169
1348
  }
2170
1349
  let outcome = execute(runtime, entry, source, ctx, finalize, wrapResponse)
2171
- if (ledger !== undefined) {
1350
+ if (ledger !== undefined && ledgerRuntime !== undefined) {
2172
1351
  const active = ledger
2173
1352
  const resolved = entry.ledgered as ResolvedEffectLedger
2174
1353
  outcome = (outcome instanceof Promise ? outcome : Promise.resolve(outcome)).then((value) =>
2175
- runtime.settleEffectLedger(active, resolved, value),
1354
+ ledgerRuntime.settle(active, resolved, value, (fields) =>
1355
+ runtime.logger.error("effect ledger sink failed", fields),
1356
+ ),
2176
1357
  )
2177
1358
  }
2178
1359
  return outcome
@@ -2180,82 +1361,58 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2180
1361
  return Object.freeze({ run, fusedWeb })
2181
1362
  }
2182
1363
 
2183
- /** 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
- })
1364
+ /** The idempotency lane's bridge back into the normal matched lanes, resolved to a concrete Response
1365
+ * (the lane buffers the body, then replays it through the route's real validation + handler). */
1366
+ private idempotencyRunLanes(
1367
+ buffered: RequestSource,
1368
+ platform: Platform | undefined,
1369
+ entry: RouteEntry,
1370
+ params: Record<string, string>,
1371
+ search: string | undefined,
1372
+ ): Promise<Response> {
1373
+ return Promise.resolve(
1374
+ this.runMatchedLanes(
1375
+ buffered,
1376
+ platform,
1377
+ entry,
1378
+ params,
1379
+ search,
1380
+ toResponse,
1381
+ IDENTITY_RESPONSE,
1382
+ RESPONSE_TIMEOUT,
1383
+ false,
1384
+ ),
1385
+ )
1386
+ }
1387
+
1388
+ /** @internal Symbol-keyed install seam for the `idempotency()` plugin. Off the public typed surface. */
1389
+ [INSTALL_IDEMPOTENCY](runtime: IdempotencyRuntime): void {
1390
+ this.assertConfigurable("idempotency()")
1391
+ this.idempotencyRuntime = runtime
1392
+ }
1393
+
1394
+ /** @internal Symbol-keyed install seam for the `mcp()` plugin. Off the public typed surface. */
1395
+ [INSTALL_MCP](runtime: McpRuntime): void {
1396
+ this.assertConfigurable("mcp()")
1397
+ this.mcpRuntime = runtime
1398
+ }
1399
+
1400
+ /** @internal Symbol-keyed install seam for the `nodeDirect()` plugin. Off the public typed surface. */
1401
+ [INSTALL_NODE_DIRECT](runtime: NodeOutcomeRuntime): void {
1402
+ this.assertConfigurable("nodeDirect()")
1403
+ this.nodeOutcomeRuntime = runtime
1404
+ }
1405
+
1406
+ /** @internal Symbol-keyed install seam for the `streaming()` plugin. Off the public typed surface. */
1407
+ [INSTALL_SSE](runtime: SseRuntime): void {
1408
+ this.assertConfigurable("streaming()")
1409
+ this.sseRuntime = runtime
2252
1410
  }
2253
1411
 
2254
- /** 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
1412
+ /** @internal Symbol-keyed install seam for the `websocket()` plugin. Off the public typed surface. */
1413
+ [INSTALL_WS](runtime: WsRuntime): void {
1414
+ this.assertConfigurable("websocket()")
1415
+ this.wsRuntime = runtime
2259
1416
  }
2260
1417
 
2261
1418
  /**
@@ -2287,6 +1444,17 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2287
1444
  )
2288
1445
  }
2289
1446
  this.catalog.addBatch(source.catalog.entries().map((route) => this.bindFusedRuntime(route)))
1447
+ // Resolved idempotency/ledger route entries carry their own store/sink configuration, while the
1448
+ // runtime object supplies the generic execution machinery. Preserve a group's installed runtime
1449
+ // when the parent has none so merging cannot silently disable a safety lane. If the parent already
1450
+ // has a runtime, either implementation can execute every resolved entry because route-specific
1451
+ // options were pinned during registration.
1452
+ this.idempotencyRuntime ??= source.idempotencyRuntime
1453
+ this.effectLedgerRuntime ??= source.effectLedgerRuntime
1454
+ this.mcpRuntime ??= source.mcpRuntime
1455
+ this.nodeOutcomeRuntime ??= source.nodeOutcomeRuntime
1456
+ this.sseRuntime ??= source.sseRuntime
1457
+ this.wsRuntime ??= source.wsRuntime
2290
1458
  this.onRequestHooks.push(...source.onRequestHooks)
2291
1459
  this.onResponseHooks.push(...source.onResponseHooks)
2292
1460
  this.onResponseFinalizedHooks.push(...source.onResponseFinalizedHooks)
@@ -2470,6 +1638,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2470
1638
  resolveNodeSource(
2471
1639
  source: RequestSource,
2472
1640
  platform?: Platform<EnvOf<Ctx>>,
1641
+ suppliedRuntime?: NodeOutcomeRuntime,
2473
1642
  ): MaybePromise<NodeServeOutcome> {
2474
1643
  // onResponse hooks transform a Response, and the capacity gate wraps the Web response path — both
2475
1644
  // force the Web path here (the gated `fetchSource` admits/sheds/releases); wrap its result.
@@ -2486,12 +1655,19 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2486
1655
  // May resolve **synchronously** for a compiled bare route + sync handler - the `@nifrajs/node`
2487
1656
  // adapter `await`s the result, so it transparently handles either; the sync case allocates no promise
2488
1657
  // at all on the Node hot path.
1658
+ const runtime = suppliedRuntime ?? this.nodeOutcomeRuntime
1659
+ if (runtime === undefined) {
1660
+ throw new FrameworkError(
1661
+ "NODE_DIRECT_RUNTIME_MISSING",
1662
+ "resolveNode() needs the Node-direct renderer. Normal @nifrajs/node serving installs it automatically; direct callers should add `.use(nodeDirect())` (from `@nifrajs/core/node-direct`).",
1663
+ )
1664
+ }
2489
1665
  return this.dispatch<NodeServeOutcome>(
2490
1666
  source,
2491
1667
  platform,
2492
- toNodeOutcome,
2493
- NODE_FROM_RESPONSE,
2494
- NODE_TIMEOUT,
1668
+ runtime.toOutcome,
1669
+ runtime.fromResponse,
1670
+ runtime.timeout,
2495
1671
  false,
2496
1672
  )
2497
1673
  }
@@ -2518,8 +1694,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2518
1694
  const params = match.params === EMPTY_PARAMS ? match.params : decodeRouteParams(match.params)
2519
1695
  if (params === null) return { kind: "reject", response: jsonError(400, "malformed_path") }
2520
1696
  const handler = match.payload.handler
2521
- // Non-null: wsRouteCount > 0 ⇒ ws() ran ⇒ the runtime created the registry.
1697
+ // Non-null: wsRouteCount > 0 ⇒ ws() ran ⇒ `.use(websocket())` installed the runtime + registry.
2522
1698
  const pubsub = this.topics as TopicRegistry
1699
+ const attach = (this.wsRuntime as WsRuntime).attach
2523
1700
  // CSWSH guard, before any per-connection work or the user's upgrade(): reject a disallowed
2524
1701
  // Origin with 403. Browsers don't CORS-protect WS handshakes but do send cookies, so this
2525
1702
  // blocks cross-site authenticated sockets when the route opts in via `allowedOrigins`.
@@ -2538,7 +1715,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2538
1715
  return { kind: "reject", response: jsonError(403, "forbidden_origin") }
2539
1716
  }
2540
1717
  if (handler.upgrade === undefined) {
2541
- return { kind: "upgrade", handler, data: undefined, pubsub }
1718
+ return { kind: "upgrade", handler, data: undefined, pubsub, attach }
2542
1719
  }
2543
1720
  const upgradeSignal = getNeverAbortSignal()
2544
1721
  const ctx = new RequestContext(
@@ -2553,7 +1730,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2553
1730
  const settle = (value: unknown): WebSocketUpgradeOutcome =>
2554
1731
  value instanceof Response
2555
1732
  ? { kind: "reject", response: value }
2556
- : { kind: "upgrade", handler, data: value, pubsub }
1733
+ : { kind: "upgrade", handler, data: value, pubsub, attach }
2557
1734
  try {
2558
1735
  const result = handler.upgrade(ctx as unknown as WebSocketContext<EnvOf<Ctx>>)
2559
1736
  return result instanceof Promise
@@ -2575,7 +1752,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2575
1752
  server: BunUpgradeServer,
2576
1753
  ): MaybePromise<Response | undefined> {
2577
1754
  const handle = (o: WebSocketUpgradeOutcome): MaybePromise<Response | undefined> => {
2578
- if (o.kind === "pass") return this.fetch(req)
1755
+ if (o.kind === "pass")
1756
+ return this.fetch(req, bunPeerPlatform(server, req) as Platform<EnvOf<Ctx>>)
2579
1757
  if (o.kind === "reject") return o.response
2580
1758
  return server.upgrade(req, { data: { handler: o.handler, data: o.data } })
2581
1759
  ? undefined
@@ -2592,6 +1770,16 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2592
1770
  * produces the 503. The Web `fetch` and `resolveNode` are thin callers over this one routing +
2593
1771
  * context + lifecycle implementation — no duplication across the trust boundary.
2594
1772
  */
1773
+ /** Apply the `clientIp` trust declaration to the adapter's raw socket peer, returning a platform
1774
+ * whose `clientIp` is the derived caller. Only called when a trust declaration is configured. */
1775
+ private deriveClientIp(
1776
+ source: RequestSource,
1777
+ platform: Platform | undefined,
1778
+ ): Platform | undefined {
1779
+ const derived = resolveClientIp(platform?.clientIp, requestOf(source), this.clientIpTrust)
1780
+ return { ...platform, clientIp: derived }
1781
+ }
1782
+
2595
1783
  private dispatch<T>(
2596
1784
  source: RequestSource,
2597
1785
  platform: Platform | undefined,
@@ -2602,12 +1790,17 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2602
1790
  // `Response` (`T = Response` there by construction; the node path always passes false).
2603
1791
  webFast: boolean,
2604
1792
  ): MaybePromise<T> {
1793
+ // Resolve the trust declaration into the platform's `clientIp` ONCE, here at the shared funnel, so
1794
+ // `c.clientIp` (and every hook/derive downstream) sees the derived caller. No config ⇒ the raw
1795
+ // socket peer the adapter supplied passes through untouched (a one-property no-op on the hot path).
1796
+ const resolved =
1797
+ this.clientIpTrust === undefined ? platform : this.deriveClientIp(source, platform)
2605
1798
  // onRequest hooks may be async, so a hooked app takes the async path; with no hooks (the common
2606
1799
  // case) routing stays synchronous, letting a bare route resolve with no lifecycle promise at all.
2607
1800
  if (this.onRequestHooks.length === 0) {
2608
- return this.routeAndRun(source, platform, finalize, wrapResponse, onTimeout, webFast)
1801
+ return this.routeAndRun(source, resolved, finalize, wrapResponse, onTimeout, webFast)
2609
1802
  }
2610
- return this.runWithOnRequest(source, platform, finalize, wrapResponse, onTimeout, webFast)
1803
+ return this.runWithOnRequest(source, resolved, finalize, wrapResponse, onTimeout, webFast)
2611
1804
  }
2612
1805
 
2613
1806
  /**
@@ -2752,8 +1945,22 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2752
1945
  ): MaybePromise<T> {
2753
1946
  // An idempotency route runs its dedupe lane first; on a fresh key it delegates to the normal lanes
2754
1947
  // (with the body buffered). All non-idempotent routes skip straight to the lanes — no added cost.
2755
- if (entry.idempotent !== undefined) {
2756
- return this.runIdempotent(source, platform, entry, params, search, wrapResponse)
1948
+ // The runtime is always present when a route resolved idempotency (enforced at registration).
1949
+ if (entry.idempotent !== undefined && this.idempotencyRuntime !== undefined) {
1950
+ return this.idempotencyRuntime.run(
1951
+ entry.idempotent,
1952
+ requestOf(source),
1953
+ platform,
1954
+ entry,
1955
+ params,
1956
+ search,
1957
+ wrapResponse,
1958
+ {
1959
+ maxBodyBytes: this.maxBodyBytes,
1960
+ runLanes: (buffered, plat, ent, prm, srch) =>
1961
+ this.idempotencyRunLanes(buffered, plat, ent as RouteEntry, prm, srch),
1962
+ },
1963
+ )
2757
1964
  }
2758
1965
  return this.runMatchedLanes(
2759
1966
  source,
@@ -2768,130 +1975,6 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2768
1975
  )
2769
1976
  }
2770
1977
 
2771
- /**
2772
- * Idempotency dedupe lane. Reads the `Idempotency-Key`, fingerprints the request, and consults the
2773
- * store: a fresh key runs the handler (via {@link runMatchedLanes} with the body buffered) and stores
2774
- * the response; a repeat key replays it without re-running; a key reused with a different body or an
2775
- * in-flight duplicate is rejected. Every branch materializes a `Response`, then adapts to `T` via the
2776
- * caller's `wrapResponse`, so it works on the Web fetch path and the Node-native outcome path alike.
2777
- */
2778
- private async runIdempotent<T>(
2779
- source: RequestSource,
2780
- platform: Platform | undefined,
2781
- entry: RouteEntry,
2782
- params: Record<string, string>,
2783
- search: string | undefined,
2784
- wrapResponse: (response: Response) => T,
2785
- ): Promise<T> {
2786
- const config = entry.idempotent as ResolvedIdempotency
2787
- const req = requestOf(source)
2788
- const key = req.headers.get(config.headerName)
2789
- if (key === null || !validIdempotencyKey(key)) {
2790
- return wrapResponse(jsonError(400, "idempotency_key_required"))
2791
- }
2792
- const namespace =
2793
- typeof config.namespace === "function"
2794
- ? await config.namespace(req.clone(), platform)
2795
- : config.namespace
2796
- if (!validIdempotencyNamespace(namespace)) {
2797
- return wrapResponse(jsonError(500, "idempotency_namespace_invalid"))
2798
- }
2799
- const read = await readBoundedBytes(req, this.maxBodyBytes)
2800
- if (!read.ok) {
2801
- return wrapResponse(
2802
- jsonError(read.status, read.status === 413 ? "payload_too_large" : "bad_request"),
2803
- )
2804
- }
2805
- const url = urlPartsOf(req.url)
2806
- const canonicalBody = canonicalizeIdempotencyBody(read.bytes, req.headers.get("content-type"))
2807
- const fingerprint = await computeIdempotencyFingerprint(
2808
- req.method,
2809
- `${url.pathname}${url.search}`,
2810
- canonicalBody,
2811
- req.headers.get("content-type") ?? "",
2812
- )
2813
-
2814
- const begin = await config.store.begin({ namespace, key, fingerprint, ttlMs: config.ttlMs })
2815
- if (begin.state === "replay") {
2816
- return wrapResponse(responseFromStored(begin.response, { maxBytes: config.maxResponseBytes }))
2817
- }
2818
- if (begin.state === "mismatch") return wrapResponse(jsonError(409, "idempotency_key_reused"))
2819
- if (begin.state === "in-flight") {
2820
- return wrapResponse(jsonError(409, "idempotency_in_progress", { "Retry-After": "1" }))
2821
- }
2822
- if (begin.state === "capacity") {
2823
- return wrapResponse(jsonError(503, "idempotency_store_capacity", { "Retry-After": "1" }))
2824
- }
2825
- const reservation = begin.reservation
2826
-
2827
- // Fresh key: re-expose the buffered body as a Request so the normal lanes can read + validate it,
2828
- // then run those lanes to a concrete Response.
2829
- const bufferedInit: RequestInit = {
2830
- method: req.method,
2831
- headers: req.headers,
2832
- signal: req.signal,
2833
- }
2834
- if (read.bytes.byteLength > 0)
2835
- bufferedInit.body = read.bytes as NonNullable<RequestInit["body"]>
2836
- const buffered: RequestSource = new Request(req.url, bufferedInit)
2837
- let response: Response
2838
- try {
2839
- response = await this.runMatchedLanes(
2840
- buffered,
2841
- platform,
2842
- entry,
2843
- params,
2844
- search,
2845
- toResponse,
2846
- IDENTITY_RESPONSE,
2847
- RESPONSE_TIMEOUT,
2848
- false,
2849
- )
2850
- } catch (err) {
2851
- // The lanes never throw (errors resolve to a 500 Response), but stay fail-safe: release the
2852
- // reservation so a retry isn't wedged, then re-throw.
2853
- await config.store.abandon({ namespace, key, reservation })
2854
- throw err
2855
- }
2856
- // Cache only successful responses; release the key on any error so the client can retry.
2857
- if (response.status >= 200 && response.status < 300) {
2858
- let storedResponse: Awaited<ReturnType<typeof serializeResponse>>
2859
- try {
2860
- storedResponse = await serializeResponse(response, { maxBytes: config.maxResponseBytes })
2861
- } catch (error) {
2862
- if (!(error instanceof IdempotencyResponseTooLargeError)) throw error
2863
- // The effect may already have happened, so never abandon and permit a duplicate execution.
2864
- // Commit a small terminal response under the winning key and return that same response now.
2865
- response = jsonError(507, "idempotency_response_too_large")
2866
- try {
2867
- storedResponse = await serializeResponse(response, {
2868
- maxBytes: config.maxResponseBytes,
2869
- })
2870
- } catch (terminalError) {
2871
- if (!(terminalError instanceof IdempotencyResponseTooLargeError)) throw terminalError
2872
- // Even an intentionally tiny bound must remain truthful. An empty 507 preserves terminal
2873
- // status + replay safety without silently storing more bytes than the route permits.
2874
- response = new Response(null, { status: 507 })
2875
- storedResponse = await serializeResponse(response, {
2876
- maxBytes: config.maxResponseBytes,
2877
- })
2878
- }
2879
- }
2880
- const completed = await config.store.complete({
2881
- namespace,
2882
- key,
2883
- reservation,
2884
- response: storedResponse,
2885
- })
2886
- if (!completed) {
2887
- return wrapResponse(jsonError(503, "idempotency_reservation_lost", { "Retry-After": "1" }))
2888
- }
2889
- } else {
2890
- await config.store.abandon({ namespace, key, reservation })
2891
- }
2892
- return wrapResponse(response)
2893
- }
2894
-
2895
1978
  /** Supply request-specific deadline state to the route's precompiled execution plan. */
2896
1979
  private runMatchedLanes<T>(
2897
1980
  source: RequestSource,
@@ -2979,32 +2062,10 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2979
2062
  return outcome
2980
2063
  }
2981
2064
 
2982
- /**
2983
- * 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
2065
+ /** @internal Symbol-keyed install seam for the `effectLedger()` plugin. Off the public typed surface. */
2066
+ [INSTALL_EFFECT_LEDGER](runtime: EffectLedgerRuntime): void {
2067
+ this.assertConfigurable("effectLedger()")
2068
+ this.effectLedgerRuntime = runtime
3008
2069
  }
3009
2070
 
3010
2071
  /** The narrowest bare route: a syntactic `() => ...` handler cannot observe the context argument, so
@@ -3650,6 +2711,15 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3650
2711
  wrapResponse: (response: Response) => T,
3651
2712
  ): Promise<T> {
3652
2713
  try {
2714
+ if (entry.schema?.params !== undefined) {
2715
+ // Path params arrive as strings from routing; validate (and coerce) them at the boundary, before
2716
+ // the body/query, so a malformed `:id` is a 422 before any work runs. Raw `.validate` (no wrapper
2717
+ // alloc), same recovery-hook path as body/query.
2718
+ const validation = entry.schema.params["~standard"].validate(ctx.params)
2719
+ const result = validation instanceof Promise ? await validation : validation
2720
+ const paramsError = await this.applyLifecycleValidation(entry, result, ctx, "params")
2721
+ if (paramsError !== undefined) return wrapResponse(paramsError)
2722
+ }
3653
2723
  if (entry.schema?.body !== undefined) {
3654
2724
  const bodyError = await this.readAndValidateBody(source, entry, ctx)
3655
2725
  if (bodyError !== undefined) return wrapResponse(bodyError)
@@ -3773,11 +2843,15 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3773
2843
  entry: RouteEntry,
3774
2844
  result: StandardResult<unknown>,
3775
2845
  ctx: RawContext,
3776
- kind: "body" | "query",
2846
+ kind: "body" | "query" | "params",
3777
2847
  ): Promise<Response | undefined> {
2848
+ const assign = (value: unknown): void => {
2849
+ if (kind === "body") ctx.body = value
2850
+ else if (kind === "query") ctx.query = value
2851
+ else ctx.params = value as Record<string, string>
2852
+ }
3778
2853
  if (result.issues === undefined) {
3779
- if (kind === "body") ctx.body = result.value
3780
- else ctx.query = result.value
2854
+ assign(result.value)
3781
2855
  return undefined
3782
2856
  }
3783
2857
  const hook = entry.schema?.onValidationError ?? this.defaultOnValidationError
@@ -3786,12 +2860,16 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3786
2860
  const recovery = attempted instanceof Promise ? await attempted : attempted
3787
2861
  if (recovery === undefined) return validationError(result.issues)
3788
2862
  if (recovery instanceof Response) return recovery
3789
- const schema = kind === "body" ? entry.schema?.body : entry.schema?.query
2863
+ const schema =
2864
+ kind === "body"
2865
+ ? entry.schema?.body
2866
+ : kind === "query"
2867
+ ? entry.schema?.query
2868
+ : entry.schema?.params
3790
2869
  const retried = schema!["~standard"].validate(recovery)
3791
2870
  const settled = retried instanceof Promise ? await retried : retried
3792
2871
  if (settled.issues !== undefined) return validationError(settled.issues)
3793
- if (kind === "body") ctx.body = settled.value
3794
- else ctx.query = settled.value
2872
+ assign(settled.value)
3795
2873
  return undefined
3796
2874
  }
3797
2875
 
@@ -3867,7 +2945,16 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3867
2945
  * route selection/upgrade. Named wildcards also stay on the fallback until Bun exposes their raw
3868
2946
  * capture semantics; static and `:param` routes take the native lane. */
3869
2947
  private buildBunNativeRoutes(): BunNativeRoutes | undefined {
3870
- if (this.onRequestHooks.length > 0 || this.wsRouteCount > 0) return undefined
2948
+ // A `clientIp` trust declaration must run the resolver in `dispatch`, which the fused native lane
2949
+ // bypasses — so an app that declares trust routes through the fetch lane (where `c.clientIp`
2950
+ // resolves) instead of Bun's native table. The allocation-free default keeps native fusion.
2951
+ if (
2952
+ this.onRequestHooks.length > 0 ||
2953
+ this.wsRouteCount > 0 ||
2954
+ this.clientIpTrust !== undefined
2955
+ ) {
2956
+ return undefined
2957
+ }
3871
2958
 
3872
2959
  const routes: BunNativeRoutes = Object.create(null) as BunNativeRoutes
3873
2960
  const mayUseFusedNative =
@@ -3938,12 +3025,12 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3938
3025
  // `server` 2nd arg is how Bun exposes `upgrade`); otherwise the lean request-only fetch. The
3939
3026
  // `websocket` handlers are one shared dispatcher — each connection's `ws.data.handler` is the
3940
3027
  // matched route's handler, set by `server.upgrade`.
3941
- // With WS routes, the dispatcher comes from the `@nifrajs/core/ws` runtime — non-null because
3942
- // wsRouteCount > 0 means ws() ran, and ws() requires the runtime at registration.
3028
+ // With WS routes, the dispatcher comes from the installed `.use(websocket())` runtime — non-null
3029
+ // because wsRouteCount > 0 means ws() ran, and ws() requires the runtime at registration.
3943
3030
  const wsHandlers =
3944
3031
  this.wsRouteCount === 0
3945
3032
  ? undefined
3946
- : (getWsRuntime() as WsRuntime).bunHandlers(this.topics as TopicRegistry)
3033
+ : (this.wsRuntime as WsRuntime).bunHandlers(this.topics as TopicRegistry)
3947
3034
  const reusePort = options?.reusePort === true
3948
3035
  // Spread rather than pass `hostname: undefined` - Bun treats an explicit undefined as a value
3949
3036
  // on some option paths, and omitting is what selects its 0.0.0.0 default.
@@ -3955,7 +3042,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3955
3042
  reusePort,
3956
3043
  ...bind,
3957
3044
  ...(nativeRoutes === undefined ? {} : { routes: nativeRoutes }),
3958
- fetch: (req: Request) => this.fetch(req),
3045
+ fetch: (req: Request, server) =>
3046
+ this.fetch(req, bunPeerPlatform(server, req) as Platform<EnvOf<Ctx>>),
3959
3047
  })
3960
3048
  : Bun.serve<BunWsData>({
3961
3049
  port,
@@ -4135,9 +3223,9 @@ export function toFetchHandler<Env = unknown>(
4135
3223
  const pair = new Pair()
4136
3224
  const server = pair[1]
4137
3225
  server.accept()
4138
- // 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, {
3226
+ // The outcome carries the installed runtime's `attach`, so `toFetchHandler` (a standalone
3227
+ // function, no access to the app's private runtime) wires the socket without a static WS import.
3228
+ outcome.attach(server, outcome.handler, outcome.data, {
4141
3229
  openNow: true,
4142
3230
  pubsub: outcome.pubsub,
4143
3231
  })