@nifrajs/core 2.2.0 → 2.4.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 (136) hide show
  1. package/README.md +2 -2
  2. package/dist/assurance.d.ts +48 -4
  3. package/dist/assurance.d.ts.map +1 -1
  4. package/dist/assurance.js +87 -9
  5. package/dist/assurance.js.map +1 -1
  6. package/dist/binary.d.ts +54 -0
  7. package/dist/binary.d.ts.map +1 -0
  8. package/dist/binary.js +90 -0
  9. package/dist/binary.js.map +1 -0
  10. package/dist/budget.d.ts +1 -1
  11. package/dist/budget.js +1 -1
  12. package/dist/capabilities.d.ts +19 -2
  13. package/dist/capabilities.d.ts.map +1 -1
  14. package/dist/capabilities.js +60 -8
  15. package/dist/capabilities.js.map +1 -1
  16. package/dist/classification.d.ts +1 -1
  17. package/dist/classification.js +1 -1
  18. package/dist/diff.d.ts +6 -6
  19. package/dist/diff.js +6 -6
  20. package/dist/durable-adapters.d.ts.map +1 -1
  21. package/dist/durable-adapters.js +36 -70
  22. package/dist/durable-adapters.js.map +1 -1
  23. package/dist/durable-execution.d.ts +2 -2
  24. package/dist/durable-execution.js +2 -2
  25. package/dist/idempotency.d.ts +3 -3
  26. package/dist/idempotency.js +4 -4
  27. package/dist/index.d.ts +3 -3
  28. package/dist/index.js +3 -3
  29. package/dist/internal/brand.d.ts +1 -1
  30. package/dist/internal/brand.js +1 -1
  31. package/dist/internal/route-catalog.d.ts +58 -0
  32. package/dist/internal/route-catalog.d.ts.map +1 -0
  33. package/dist/internal/route-catalog.js +58 -0
  34. package/dist/internal/route-catalog.js.map +1 -0
  35. package/dist/internal/route-execution.d.ts +75 -0
  36. package/dist/internal/route-execution.d.ts.map +1 -0
  37. package/dist/internal/route-execution.js +2 -0
  38. package/dist/internal/route-execution.js.map +1 -0
  39. package/dist/internal/same-origin.d.ts +29 -0
  40. package/dist/internal/same-origin.d.ts.map +1 -0
  41. package/dist/internal/same-origin.js +47 -0
  42. package/dist/internal/same-origin.js.map +1 -0
  43. package/dist/internal/secret.d.ts +1 -1
  44. package/dist/internal/secret.js +1 -1
  45. package/dist/ledger.d.ts +9 -9
  46. package/dist/ledger.js +5 -5
  47. package/dist/reflection.d.ts +1 -1
  48. package/dist/router/pattern.d.ts +9 -0
  49. package/dist/router/pattern.d.ts.map +1 -1
  50. package/dist/router/pattern.js +13 -4
  51. package/dist/router/pattern.js.map +1 -1
  52. package/dist/router/router.js +3 -3
  53. package/dist/schema/standard.d.ts +2 -2
  54. package/dist/schema/standard.js +2 -2
  55. package/dist/seo.d.ts +3 -3
  56. package/dist/seo.js +4 -4
  57. package/dist/server/body.d.ts +2 -2
  58. package/dist/server/body.js +2 -2
  59. package/dist/server/context.d.ts +18 -18
  60. package/dist/server/contract.d.ts +7 -7
  61. package/dist/server/contract.js +3 -3
  62. package/dist/server/cookies.d.ts +4 -4
  63. package/dist/server/cookies.js +8 -8
  64. package/dist/server/edge.d.ts +83 -0
  65. package/dist/server/edge.d.ts.map +1 -0
  66. package/dist/server/edge.js +71 -0
  67. package/dist/server/edge.js.map +1 -0
  68. package/dist/server/install.d.ts +2 -0
  69. package/dist/server/install.d.ts.map +1 -1
  70. package/dist/server/install.js +2 -0
  71. package/dist/server/install.js.map +1 -1
  72. package/dist/server/logger.d.ts +7 -7
  73. package/dist/server/logger.js +4 -4
  74. package/dist/server/registry.d.ts +5 -5
  75. package/dist/server/response-contract-lane.d.ts +91 -0
  76. package/dist/server/response-contract-lane.d.ts.map +1 -0
  77. package/dist/server/response-contract-lane.js +81 -0
  78. package/dist/server/response-contract-lane.js.map +1 -0
  79. package/dist/server/server.d.ts +52 -91
  80. package/dist/server/server.d.ts.map +1 -1
  81. package/dist/server/server.js +130 -186
  82. package/dist/server/server.js.map +1 -1
  83. package/dist/server/sse.d.ts +4 -4
  84. package/dist/server/sse.js +7 -7
  85. package/dist/server/webhook.d.ts +1 -1
  86. package/dist/server/webhook.js +5 -5
  87. package/dist/server/websocket.d.ts +19 -19
  88. package/dist/server/websocket.js +9 -9
  89. package/dist/server/ws-bun.d.ts +4 -4
  90. package/dist/server/ws-bun.js +3 -3
  91. package/dist/server.d.ts +3 -1
  92. package/dist/server.d.ts.map +1 -1
  93. package/dist/server.js +3 -1
  94. package/dist/server.js.map +1 -1
  95. package/dist/transport-codec.d.ts +12 -0
  96. package/dist/transport-codec.d.ts.map +1 -1
  97. package/dist/transport-codec.js +19 -4
  98. package/dist/transport-codec.js.map +1 -1
  99. package/package.json +12 -2
  100. package/src/assurance.ts +154 -8
  101. package/src/binary.ts +128 -0
  102. package/src/budget.ts +1 -1
  103. package/src/capabilities.ts +71 -9
  104. package/src/classification.ts +1 -1
  105. package/src/diff.ts +8 -8
  106. package/src/durable-adapters.ts +40 -70
  107. package/src/durable-execution.ts +2 -2
  108. package/src/idempotency.ts +4 -4
  109. package/src/index.ts +3 -3
  110. package/src/internal/brand.ts +1 -1
  111. package/src/internal/route-catalog.ts +94 -0
  112. package/src/internal/route-execution.ts +106 -0
  113. package/src/internal/same-origin.ts +43 -0
  114. package/src/internal/secret.ts +1 -1
  115. package/src/ledger.ts +11 -11
  116. package/src/reflection.ts +1 -1
  117. package/src/router/pattern.ts +16 -4
  118. package/src/router/router.ts +5 -5
  119. package/src/schema/standard.ts +2 -2
  120. package/src/seo.ts +6 -6
  121. package/src/server/body.ts +2 -2
  122. package/src/server/context.ts +18 -18
  123. package/src/server/contract.ts +13 -13
  124. package/src/server/cookies.ts +8 -8
  125. package/src/server/edge.ts +150 -0
  126. package/src/server/install.ts +5 -0
  127. package/src/server/logger.ts +8 -8
  128. package/src/server/registry.ts +5 -5
  129. package/src/server/response-contract-lane.ts +164 -0
  130. package/src/server/server.ts +159 -366
  131. package/src/server/sse.ts +11 -11
  132. package/src/server/webhook.ts +5 -5
  133. package/src/server/websocket.ts +22 -22
  134. package/src/server/ws-bun.ts +4 -4
  135. package/src/server.ts +8 -5
  136. package/src/transport-codec.ts +24 -5
@@ -25,13 +25,24 @@ import {
25
25
  NIFRA_ASSURANCE_IDS,
26
26
  validEvidenceId,
27
27
  } from "../internal/route-assurance.ts"
28
+ import { type CatalogRoute, RouteCatalog } from "../internal/route-catalog.ts"
29
+ import type {
30
+ ContextRouteRunner,
31
+ FusedWebRunner,
32
+ InternalHandler,
33
+ RawAfterHandle,
34
+ RawAround,
35
+ RawBeforeHandle,
36
+ RawDerive,
37
+ RawErrorHandler,
38
+ RouteEntry,
39
+ RouteExecutionPlan,
40
+ RouteExecutionRunner,
41
+ } from "../internal/route-execution.ts"
42
+ import { isSameOriginRequest } from "../internal/same-origin.ts"
28
43
  import type { RequestLedger } from "../ledger.ts"
29
- import {
30
- type CompiledRoutePattern,
31
- compileRoutePattern,
32
- decodeRouteParams,
33
- } from "../router/pattern.ts"
34
- import { EMPTY_PARAMS, type Method, Router, type RouterMatch } from "../router/router.ts"
44
+ import { compileRoutePattern, decodeRouteParams } from "../router/pattern.ts"
45
+ import { EMPTY_PARAMS, type Method, Router } from "../router/router.ts"
35
46
  import type {
36
47
  InferOutput,
37
48
  StandardIssue,
@@ -53,6 +64,9 @@ import {
53
64
  } from "./query.ts"
54
65
  import { RequestContext, readBoundedJsonSource } from "./request-context.ts"
55
66
  import { fusedRespond, fusedRespondNoSet, toResponse } from "./respond.ts"
67
+ // Type-only: erased, so the kernel never pulls the lane's implementation into a bundle that does not
68
+ // install the plugin. The value side arrives through the symbol-keyed install seam.
69
+ import type { ResponseContractRuntime } from "./response-contract-lane.ts"
56
70
  import {
57
71
  CONTEXT_SEARCH,
58
72
  CONTEXT_SET,
@@ -68,12 +82,13 @@ import {
68
82
  // so existing importers keep resolving it from the server module.
69
83
  export type { NodeServeOutcome }
70
84
 
71
- import type { IdempotencyRuntime, ResolvedIdempotency } from "./idempotency-lane.ts"
85
+ import type { IdempotencyRuntime } from "./idempotency-lane.ts"
72
86
  import {
73
87
  INSTALL_EFFECT_LEDGER,
74
88
  INSTALL_IDEMPOTENCY,
75
89
  INSTALL_MCP,
76
90
  INSTALL_NODE_DIRECT,
91
+ INSTALL_RESPONSE_CONTRACT,
77
92
  INSTALL_SSE,
78
93
  INSTALL_WS,
79
94
  } from "./install.ts"
@@ -106,7 +121,6 @@ import type {
106
121
  import type { SSEInit, TypedSSEStream } from "./sse.ts"
107
122
  import type { SseRuntime } from "./sse-hook.ts"
108
123
  import type {
109
- StandardWebSocket,
110
124
  TopicRegistry,
111
125
  WebSocketContext,
112
126
  WebSocketHandler,
@@ -119,10 +133,10 @@ export type MaybePromise<T> = T | Promise<T>
119
133
 
120
134
  /**
121
135
  * Internal request view. A real Web `Request` already satisfies this shape, so Web/edge runtimes pass
122
- * their `Request` **directly** (zero wrapper allocation on the hot path — `request` is simply absent and
136
+ * their `Request` **directly** (zero wrapper allocation on the hot path - `request` is simply absent and
123
137
  * {@link requestOf} returns the source itself). Node's adapter passes a *lazy* source whose `request`
124
138
  * getter builds an undici `Request` only when user code reads `c.req`, an onRequest/onResponse hook
125
- * needs it, or a body helper consumes it — so the common Node request never pays for a `Request` build.
139
+ * needs it, or a body helper consumes it - so the common Node request never pays for a `Request` build.
126
140
  */
127
141
  export interface RequestSource {
128
142
  readonly method: string
@@ -176,154 +190,15 @@ export interface RawContext {
176
190
  readonly boundedJson: <T = unknown>(maxBytes?: number) => Promise<T>
177
191
  }
178
192
 
179
- type InternalHandler = (ctx: RawContext) => MaybePromise<HandlerResult>
180
-
181
193
  /** Broad shape so the implementation signature is compatible with both typed overloads. */
182
194
  type ErasedHandler = (ctx: never) => MaybePromise<HandlerResult>
183
195
 
184
- /** A `derive` computes per-request context extensions; stored path-erased. */
185
- type RawDerive = (ctx: RawContext) => MaybePromise<object>
186
- type RawBeforeHandle = (ctx: RawContext) => MaybePromise<unknown>
187
- type RawAfterHandle = (result: unknown, ctx: RawContext) => MaybePromise<unknown>
188
- type RawErrorHandler = (error: unknown, ctx: RawContext) => MaybePromise<unknown>
189
- type RawAround = <T>(ctx: RawContext, next: () => MaybePromise<T>) => MaybePromise<T>
190
196
  export type OnRequestResult = Response | Request | undefined
191
197
  type RawOnRequest = (req: Request, platform?: Platform) => MaybePromise<OnRequestResult>
192
198
  type RawOnResponse = (response: Response, req: Request) => MaybePromise<Response>
193
199
  type RawOnResponseFinalized = (outcome: ResponseFinalization, req: Request) => MaybePromise<void>
194
200
 
195
- type RouteExecutionRunner = <T, R extends Registry, Ctx>(
196
- runtime: Server<R, Ctx>,
197
- entry: RouteEntry,
198
- source: RequestSource,
199
- params: Record<string, string>,
200
- search: string | undefined,
201
- signal: AbortSignal,
202
- budget: RequestBudget,
203
- platform: Platform | undefined,
204
- finalize: (result: unknown, set: CtxSet) => T,
205
- wrapResponse: (response: Response) => T,
206
- ) => MaybePromise<T>
207
-
208
- type ContextRouteRunner = <T, R extends Registry, Ctx>(
209
- runtime: Server<R, Ctx>,
210
- entry: RouteEntry,
211
- source: RequestSource,
212
- ctx: RawContext,
213
- finalize: (result: unknown, set: CtxSet) => T,
214
- wrapResponse: (response: Response) => T,
215
- ) => MaybePromise<T>
216
-
217
- /** Registration-compiled route behavior. Every adapter invokes the same runner; the optional fused
218
- * renderer is only a response-format specialization of that same selected route semantics. */
219
- interface RouteExecutionPlan {
220
- readonly run: RouteExecutionRunner
221
- readonly fusedWeb: FusedWebRunner | undefined
222
- }
223
-
224
- interface RouteEntry {
225
- readonly handler: InternalHandler
226
- readonly schema: RouteSchema | undefined
227
- /** Resolved idempotency config; `undefined` = off (the dedupe lane is never entered). */
228
- readonly idempotent: ResolvedIdempotency | undefined
229
- /** Resolved effect-ledger wiring; `undefined` = off (no per-request ledger, no settle step). */
230
- readonly ledgered: ResolvedEffectLedger | undefined
231
- /** Per-request context extensions captured at registration (order-scoped). */
232
- readonly derives: ReadonlyArray<RawDerive>
233
- /** Static context extensions captured at registration. */
234
- readonly decorations: Record<PropertyKey, unknown>
235
- /** Whether {@link decorations} has any keys — precomputed so the hot path skips a no-op
236
- * `Object.assign` on the (common) no-decoration route. */
237
- readonly hasDecorations: boolean
238
- /** Lifecycle hooks captured at registration (order-scoped). */
239
- readonly beforeHandle: ReadonlyArray<RawBeforeHandle>
240
- readonly afterHandle: ReadonlyArray<RawAfterHandle>
241
- readonly onError: ReadonlyArray<RawErrorHandler>
242
- /** Wraps the matched route lifecycle. Empty for the common no-around path. */
243
- readonly around: ReadonlyArray<RawAround>
244
- /** The single immutable execution decision consumed by portable, Node-direct, and Bun-native paths. */
245
- readonly execution: RouteExecutionPlan
246
- }
247
-
248
- /** One canonical runtime route fact. The catalog owns matching, reflection, assurance, tool metadata,
249
- * replay, and native compilation input so batch registration has one commit point. */
250
- interface CatalogRoute {
251
- readonly method: Method
252
- readonly path: string
253
- readonly pattern: CompiledRoutePattern
254
- readonly entry: RouteEntry
255
- readonly descriptor: RouteDescriptor
256
- readonly assurance: readonly AssuranceDeclaration[]
257
- }
258
-
259
- /**
260
- * Runtime route catalog. Single-route registration mutates directly; multi-route registration replays
261
- * the existing catalog plus the candidate batch into a staged router, then swaps the complete state only
262
- * after every route validates. Failed `implement()`/`merge()` batches therefore leave matching and
263
- * reflection unchanged.
264
- */
265
- class RouteCatalog {
266
- private matcher = new Router<RouteEntry>()
267
- private records: CatalogRoute[] = []
268
- /** Allocation-free reflection view for the common no-assurance case. Derived only at commit time. */
269
- private descriptors: RouteDescriptor[] = []
270
- private assurancePresent = false
271
-
272
- add(route: CatalogRoute): void {
273
- this.matcher.add(route.method, route.pattern, route.entry)
274
- this.records.push(route)
275
- this.descriptors.push(route.descriptor)
276
- if (route.assurance.length > 0) this.assurancePresent = true
277
- }
278
-
279
- addBatch(routes: readonly CatalogRoute[]): void {
280
- if (routes.length === 0) return
281
- const nextRecords = this.records.concat(routes)
282
- const nextDescriptors = this.descriptors.concat(routes.map(({ descriptor }) => descriptor))
283
- const nextAssurancePresent =
284
- this.assurancePresent || routes.some((route) => route.assurance.length > 0)
285
- const staged = new Router<RouteEntry>()
286
- for (const route of this.records) staged.add(route.method, route.pattern, route.entry)
287
- for (const route of routes) staged.add(route.method, route.pattern, route.entry)
288
- this.matcher = staged
289
- this.records = nextRecords
290
- this.descriptors = nextDescriptors
291
- this.assurancePresent = nextAssurancePresent
292
- }
293
-
294
- find(method: string, path: string): RouterMatch<RouteEntry> {
295
- return this.matcher.find(method, path)
296
- }
297
-
298
- entries(): readonly CatalogRoute[] {
299
- return this.records
300
- }
301
-
302
- routeDescriptors(): ReadonlyArray<RouteDescriptor> {
303
- return this.descriptors
304
- }
305
-
306
- lastDescriptor(): RouteDescriptor | undefined {
307
- return this.records[this.records.length - 1]?.descriptor
308
- }
309
-
310
- hasAssurance(): boolean {
311
- return this.assurancePresent
312
- }
313
- }
314
-
315
- /** The fused Web lane: same inputs `routeAndRun` would hand the generic path, a `Response` out. */
316
- type FusedWebRunner = (
317
- source: RequestSource,
318
- params: Record<string, string>,
319
- search: string | undefined,
320
- signal: AbortSignal,
321
- budget: RequestBudget,
322
- platform: Platform | undefined,
323
- nativeContext: boolean,
324
- ) => MaybePromise<Response>
325
-
326
- /** A registered WebSocket route — just its handler; matching reuses {@link Router} under the GET verb. */
201
+ /** A registered WebSocket route - just its handler; matching reuses {@link Router} under the GET verb. */
327
202
  interface WsEntry {
328
203
  readonly handler: WebSocketHandler
329
204
  }
@@ -383,7 +258,7 @@ function requireMcpRuntime(runtime: McpRuntime | undefined): McpRuntime {
383
258
  }
384
259
 
385
260
  /** The handler's permitted return type. When the route declares a `response` schema, the return is
386
- * constrained to the contract's type (or a raw `Response`) — so the implementation can't drift from the
261
+ * constrained to the contract's type (or a raw `Response`) - so the implementation can't drift from the
387
262
  * declared contract. Without a `response` schema it's unconstrained (`HandlerResult`), exactly as before. */
388
263
  type ResponseOf<S extends RouteSchema> = S extends { response: infer R extends StandardSchemaV1 }
389
264
  ? InferOutput<R> | Response
@@ -435,16 +310,10 @@ const DEFAULT_MAX_BODY_BYTES = 1_000_000
435
310
  const DEFAULT_DRAIN_MS = 10_000
436
311
  const DRAIN_POLL_MS = 10
437
312
 
438
- /** Same-origin check for a WebSocket handshake (CSWSH default): the `Origin`'s host[:port] must equal the
439
- * request's own host (from `req.url`, which the runtime builds from the `Host` header). Scheme differs
440
- * (ws↔http), so compare host only. An unparseable Origin is treated as NOT same-origin (rejected). */
441
- function wsSameOrigin(origin: string, req: Request): boolean {
442
- try {
443
- return new URL(origin).host === new URL(req.url).host
444
- } catch {
445
- return false
446
- }
447
- }
313
+ /** Same-origin check for a WebSocket handshake (CSWSH default). {@link isSameOriginRequest} is the one
314
+ * owner, shared with the server-function mount in `@nifrajs/web` - the two used to answer differently
315
+ * for the same request, so a browser that could open a socket was told its POST was cross-origin. */
316
+ const wsSameOrigin = isSameOriginRequest
448
317
 
449
318
  function validationError(issues: ReadonlyArray<StandardIssue>): Response {
450
319
  const serialized = issues.map((issue) => {
@@ -472,7 +341,7 @@ function hasReplacementParam(params: Record<string, string>): boolean {
472
341
  * anything when no handler touched `c.set.*`. Server-internal. */
473
342
  export type CtxSet = ResponseControls & {
474
343
  _headers?: Record<string, string>
475
- /** Accumulated `Set-Cookie` values — a list, since a `Record` would collapse multiple cookies. */
344
+ /** Accumulated `Set-Cookie` values - a list, since a `Record` would collapse multiple cookies. */
476
345
  _cookies?: string[]
477
346
  }
478
347
 
@@ -507,12 +376,15 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
507
376
  private readonly wsRouter: Router<WsEntry>
508
377
  private wsRouteCount: number
509
378
  /** In-process pub/sub backing `ws.subscribe(topic)` + `app.publish(topic, data)` (single-instance).
510
- * Created by the first `app.ws()` via the `@nifrajs/core/ws` runtime — `undefined` until then, so a
379
+ * Created by the first `app.ws()` via the `@nifrajs/core/ws` runtime - `undefined` until then, so a
511
380
  * no-WebSocket app never constructs (or bundles) it. */
512
381
  private topics: TopicRegistry | undefined
513
382
  private readonly maxBodyBytes: number
514
383
  private readonly wsMaxPayloadBytes: number
515
384
  private readonly requestTimeoutMs: number
385
+ /** Installed by the `responseContract()` plugin; `undefined` = not installed, which is the default
386
+ * and the state in which a declared `response` schema stays a compile-time contract only. */
387
+ private responseContractRuntime: ResponseContractRuntime | undefined
516
388
  /** Opt-in caller-IP trust declaration; `undefined` = socket peer only, no forwarded header believed. */
517
389
  private readonly clientIpTrust: ClientIpTrust | undefined
518
390
  private readonly acceptInboundDeadlines: boolean
@@ -553,7 +425,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
553
425
  private readonly onResponseHooks: RawOnResponse[]
554
426
  private readonly onResponseFinalizedHooks: RawOnResponseFinalized[]
555
427
  private readonly responseRequests: WeakMap<Request, Request>
556
- /** Names of plugins/middleware already applied via `use` — for idempotent dedupe. */
428
+ /** Names of plugins/middleware already applied via `use` - for idempotent dedupe. */
557
429
  private readonly appliedPlugins: Set<string>
558
430
  /** Order-scoped evidence captured by routes registered after an assured plugin. */
559
431
  private readonly activeAssurance: AssuranceDeclaration[]
@@ -593,6 +465,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
593
465
  this.effectLedgerRuntime = undefined
594
466
  // The idempotency runtime is installed by `.use(idempotency())`; a bare app never imports it, so the
595
467
  // dedupe machinery tree-shakes out. A route that declares idempotency without it is a build error.
468
+ this.responseContractRuntime = undefined
596
469
  this.idempotencyRuntime = undefined
597
470
  this.logger = options.logger ?? jsonLogger()
598
471
  this.defaultOnValidationError = options.onValidationError
@@ -716,7 +589,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
716
589
  return this
717
590
  }
718
591
 
719
- /** Transform every outgoing response — success, error, 404, 405, short-circuit. Global. */
592
+ /** Transform every outgoing response - success, error, 404, 405, short-circuit. Global. */
720
593
  onResponse(fn: (response: Response, req: Request) => MaybePromise<Response>): this {
721
594
  this.assertConfigurable("onResponse()")
722
595
  this.onResponseHooks.push(fn)
@@ -733,7 +606,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
733
606
  }
734
607
 
735
608
  /**
736
- * Apply a type-**identity** plugin ({@link IdentityPlugin}, from {@link defineIdentityPlugin}) — it
609
+ * Apply a type-**identity** plugin ({@link IdentityPlugin}, from {@link defineIdentityPlugin}) - it
737
610
  * registers routes/hooks but doesn't change the types, so this returns `this` with the route registry
738
611
  * and context fully intact. This overload exists specifically so a *named* identity plugin (e.g.
739
612
  * `@nifrajs/better-auth`) threads the registry: its `& { pluginName }` intersection would otherwise
@@ -741,14 +614,14 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
741
614
  */
742
615
  use(plugin: IdentityPlugin): this
743
616
  /**
744
- * Apply a **plugin function** — `(app) => app`, typically built with {@link definePlugin}. It's
617
+ * Apply a **plugin function** - `(app) => app`, typically built with {@link definePlugin}. It's
745
618
  * called with `this` and its result is returned, so an inline plugin's `derive`/`decorate` thread
746
619
  * the added context to handlers defined after `use` (the overload is generic over the concrete
747
620
  * `this`). A named plugin already applied is skipped (idempotent dedupe).
748
621
  */
749
622
  use<Out extends AnyServer>(plugin: (app: this) => Out): Out
750
623
  /**
751
- * Apply a {@link Middleware} bundle — wire each hook it provides to its lifecycle point. Returns
624
+ * Apply a {@link Middleware} bundle - wire each hook it provides to its lifecycle point. Returns
752
625
  * `this` (no context-type merging); call it before the routes its `beforeHandle`/`afterHandle`
753
626
  * should cover (those are order-scoped; `onRequest`/`onResponse` are global). A named bundle already
754
627
  * applied is skipped (idempotent).
@@ -816,11 +689,11 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
816
689
  }
817
690
 
818
691
  /**
819
- * Register a **typed SSE route** — a GET endpoint streaming `text/event-stream` whose event
692
+ * Register a **typed SSE route** - a GET endpoint streaming `text/event-stream` whose event
820
693
  * payloads are contracted by `schema.sse`. The handler receives the validated context plus a
821
694
  * {@link TypedSSEStream}: `stream.send(event)` is compile-time-checked against the schema and
822
695
  * JSON-serialized into the SSE `data:` field. The typed client sees the marker and grows a
823
- * `.subscribe(onEvent)` for the route with the same payload type — end-to-end typed streaming.
696
+ * `.subscribe(onEvent)` for the route with the same payload type - end-to-end typed streaming.
824
697
  *
825
698
  * import { streaming } from "@nifrajs/core/sse" // .use(streaming()) enables .sse()
826
699
  * const app = server().use(streaming()).sse("/feed", { sse: t.object({ id: t.integer(), title: t.string() }) },
@@ -982,7 +855,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
982
855
  )
983
856
  this.register("POST", plan.path, plan.schema, plan.run as (context: never) => unknown)
984
857
  // Tag the just-registered descriptor as an MCP tool. `tool` is readonly on RouteDescriptor (an
985
- // introspection field), so write it through a narrow mutable view — not `any`.
858
+ // introspection field), so write it through a narrow mutable view - not `any`.
986
859
  const lastRoute = this.catalog.lastDescriptor()
987
860
  if (lastRoute) {
988
861
  ;(lastRoute as { tool?: RouteDescriptor["tool"] }).tool = plan.descriptor
@@ -991,7 +864,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
991
864
  }
992
865
 
993
866
  /**
994
- * Declare an MCP **resource** — read-only data an agent can fetch through `nifra mcp` (app config, a
867
+ * Declare an MCP **resource** - read-only data an agent can fetch through `nifra mcp` (app config, a
995
868
  * generated document, …). `read` runs in the app process, so capture whatever app state it needs in the
996
869
  * closure. `uri` is the MCP resource identifier (e.g. `"myapp://config"`). The sibling of {@link tool}
997
870
  * for the resource half of MCP.
@@ -1007,7 +880,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1007
880
  }
1008
881
 
1009
882
  /**
1010
- * Declare an MCP **prompt** — a reusable prompt template an agent can fetch through `nifra mcp`.
883
+ * Declare an MCP **prompt** - a reusable prompt template an agent can fetch through `nifra mcp`.
1011
884
  * `handler` receives the caller's arguments and returns the rendered messages.
1012
885
  */
1013
886
  prompt(
@@ -1020,12 +893,12 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1020
893
  return this
1021
894
  }
1022
895
 
1023
- /** The MCP resources declared via {@link resource} — enumerated by `nifra mcp`. */
896
+ /** The MCP resources declared via {@link resource} - enumerated by `nifra mcp`. */
1024
897
  mcpResources(): readonly McpResourceDescriptor[] {
1025
898
  return this.mcpResourceList
1026
899
  }
1027
900
 
1028
- /** The MCP prompts declared via {@link prompt} — enumerated by `nifra mcp`. */
901
+ /** The MCP prompts declared via {@link prompt} - enumerated by `nifra mcp`. */
1029
902
  mcpPrompts(): readonly McpPromptDescriptor[] {
1030
903
  return this.mcpPromptList
1031
904
  }
@@ -1034,7 +907,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1034
907
  * Register a **WebSocket** route. The connection upgrades on a `GET` to `path` carrying
1035
908
  * `Upgrade: websocket`; the optional `handler.upgrade(c)` runs in the request context first and may
1036
909
  * reject (return a `Response`) or seed per-connection `ws.data`. WebSockets are served by the
1037
- * adapter (`listen()`, `@nifrajs/node`, `@nifrajs/deno`, `toFetchHandler`) — not by bare `app.fetch`, which
910
+ * adapter (`listen()`, `@nifrajs/node`, `@nifrajs/deno`, `toFetchHandler`) - not by bare `app.fetch`, which
1038
911
  * has no socket (a WS path through `app.fetch` is a normal HTTP response).
1039
912
  *
1040
913
  * The route also enters the type-level registry (under the pseudo-method `"WS"`), so the typed
@@ -1058,10 +931,10 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1058
931
  : Server<AddRoute<R, "WS", Path, WsRouteInfoFor<Path, Schema, Send>>, Ctx> {
1059
932
  this.assertConfigurable("ws()")
1060
933
  // Boot-time guard: the WS runtime is a subpath (`@nifrajs/core/ws`) so no-WebSocket apps don't
1061
- // bundle it. Registration is the loud, early failure point — never the first connection.
934
+ // bundle it. Registration is the loud, early failure point - never the first connection.
1062
935
  const runtime = requireWsRuntime(this.wsRuntime)
1063
936
  this.topics ??= runtime.createTopics()
1064
- // A `messageSchema` wraps `message` with validation once, here — every adapter then dispatches
937
+ // A `messageSchema` wraps `message` with validation once, here - every adapter then dispatches
1065
938
  // already-validated, typed messages (Bun/Deno/Node/Workers) with no per-adapter code.
1066
939
  this.wsRouter.add("GET", path, {
1067
940
  handler: runtime.wrapHandler(handler as WebSocketHandler),
@@ -1072,11 +945,11 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1072
945
 
1073
946
  /**
1074
947
  * Broadcast `data` to every WebSocket connection subscribed to `topic` (via `ws.subscribe(topic)`).
1075
- * In-process and **single-instance** (see {@link TopicRegistry}) — a multi-instance deploy must bridge
948
+ * In-process and **single-instance** (see {@link TopicRegistry}) - a multi-instance deploy must bridge
1076
949
  * an external fan-out (Redis, a Durable Object) to this. A no-op when nobody is subscribed.
1077
950
  */
1078
951
  publish(topic: string, data: string | ArrayBufferView | ArrayBuffer): void {
1079
- // No `app.ws()` yet ⇒ no registry and necessarily no subscribers — a publish is a no-op anyway.
952
+ // No `app.ws()` yet ⇒ no registry and necessarily no subscribers - a publish is a no-op anyway.
1080
953
  this.topics?.publish(topic, data)
1081
954
  }
1082
955
 
@@ -1103,7 +976,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1103
976
 
1104
977
  /**
1105
978
  * Low-level route registration shared by the inline builder and `implement()`.
1106
- * Captures the server's current `derive`/`decorate` chain into the route — this
979
+ * Captures the server's current `derive`/`decorate` chain into the route - this
1107
980
  * is the "compiled", order-scoped per-route chain.
1108
981
  */
1109
982
  register(
@@ -1170,7 +1043,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1170
1043
  }
1171
1044
  const hasDecorations = Reflect.ownKeys(routeDecorations).length > 0
1172
1045
  // An idempotency route runs a dedupe lane that must buffer the body and capture the response, so it
1173
- // never takes the fused/native fast path — force it onto the portable matched lane (which routes
1046
+ // never takes the fused/native fast path - force it onto the portable matched lane (which routes
1174
1047
  // through `fetchMatched`, where the dedupe wrapper lives). Fail closed: a route may not declare
1175
1048
  // idempotency unless the idempotency runtime is installed, so the safety gate can never be silently
1176
1049
  // dropped by a missing plugin.
@@ -1183,18 +1056,27 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1183
1056
  const idempotent = this.idempotencyRuntime?.resolve(schema, authenticated, this.maxBodyBytes)
1184
1057
  // A ledgered route (capabilities declared + `.use(effectLedger())`) needs a per-request
1185
1058
  // context to carry the ledger and a settle step to seal + sink it, so it too leaves the
1186
- // fused/contextless fast path. Resolved per route, at registration — like the capability guard.
1059
+ // fused/contextless fast path. Resolved per route, at registration - like the capability guard.
1187
1060
  const ledgered: ResolvedEffectLedger | undefined = this.effectLedgerRuntime?.resolve(
1188
1061
  capabilities,
1189
1062
  method,
1190
1063
  path,
1191
1064
  )
1065
+ // A checked response schema needs the handler's VALUE before it becomes bytes, and the fused and
1066
+ // native lanes exist precisely to skip that step. So a contracted route leaves them - the same
1067
+ // trade an idempotent route makes above, and the reason this is opt-in per server.
1068
+ const runtime = this.responseContractRuntime
1069
+ const contracted =
1070
+ runtime !== undefined && schema?.response !== undefined
1071
+ ? { runtime, schema: schema.response }
1072
+ : undefined
1192
1073
  const bare =
1193
1074
  schema?.params === undefined &&
1194
1075
  schema?.body === undefined &&
1195
1076
  schema?.query === undefined &&
1196
1077
  idempotent === undefined &&
1197
1078
  ledgered === undefined &&
1079
+ contracted === undefined &&
1198
1080
  this.derives.length === 0 &&
1199
1081
  this.beforeHandleHooks.length === 0 &&
1200
1082
  this.afterHandleHooks.length === 0 &&
@@ -1208,9 +1090,12 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1208
1090
  )
1209
1091
  : undefined
1210
1092
  const contextless = bare && this.aroundHooks.length === 0 && isContextlessNoArgArrow(handler)
1093
+ // The body and query lanes finalize the handler's result themselves, so a contracted route takes
1094
+ // the general lifecycle lane instead - one place owns the check rather than three.
1211
1095
  const lane = bare
1212
1096
  ? "bare"
1213
- : schema?.body !== undefined &&
1097
+ : contracted === undefined &&
1098
+ schema?.body !== undefined &&
1214
1099
  schema.query === undefined &&
1215
1100
  schema.params === undefined &&
1216
1101
  this.derives.length === 0 &&
@@ -1218,7 +1103,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1218
1103
  this.afterHandleHooks.length === 0 &&
1219
1104
  this.onErrorHooks.length === 0
1220
1105
  ? "body"
1221
- : schema?.body === undefined &&
1106
+ : contracted === undefined &&
1107
+ schema?.body === undefined &&
1222
1108
  schema?.query !== undefined &&
1223
1109
  schema.params === undefined &&
1224
1110
  this.derives.length === 0 &&
@@ -1241,6 +1127,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1241
1127
  schema,
1242
1128
  idempotent,
1243
1129
  ledgered,
1130
+ responseContract: contracted,
1244
1131
  derives: [...this.derives],
1245
1132
  decorations: routeDecorations,
1246
1133
  hasDecorations,
@@ -1431,6 +1318,12 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1431
1318
  )
1432
1319
  }
1433
1320
 
1321
+ /** @internal Symbol-keyed install seam for the `responseContract()` plugin. Off the public typed surface. */
1322
+ [INSTALL_RESPONSE_CONTRACT](runtime: ResponseContractRuntime): void {
1323
+ this.assertConfigurable("responseContract()")
1324
+ this.responseContractRuntime = runtime
1325
+ }
1326
+
1434
1327
  /** @internal Symbol-keyed install seam for the `idempotency()` plugin. Off the public typed surface. */
1435
1328
  [INSTALL_IDEMPOTENCY](runtime: IdempotencyRuntime): void {
1436
1329
  this.assertConfigurable("idempotency()")
@@ -1462,16 +1355,16 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1462
1355
  }
1463
1356
 
1464
1357
  /**
1465
- * Merge another server's routes into this one — the composition escape hatch for large apps.
1358
+ * Merge another server's routes into this one - the composition escape hatch for large apps.
1466
1359
  *
1467
1360
  * WHY: the fluent chain accumulates one type-alias level per route, and TypeScript resolves
1468
- * that stack in one recursion — a single chain hits TS2589 at ~95 routes. Groups keep every
1361
+ * that stack in one recursion - a single chain hits TS2589 at ~95 routes. Groups keep every
1469
1362
  * chain short: build each domain (`listings`, `agents`, …) as its own `server()` (its registry
1470
- * resolves independently), then `app.merge(listings).merge(agents)` — each merge adds ONE level
1363
+ * resolves independently), then `app.merge(listings).merge(agents)` - each merge adds ONE level
1471
1364
  * regardless of group size. 300+ routes stay fully typed (see many-routes.test-d.ts). The
1472
1365
  * other escape hatch is contract-first `implement()`, whose registry is a single object type.
1473
1366
  *
1474
- * Semantics: merged routes keep the chains captured where they were DEFINED — the group's
1367
+ * Semantics: merged routes keep the chains captured where they were DEFINED - the group's
1475
1368
  * `derive`/`decorate`/`beforeHandle`/`afterHandle`/`onError`/`around` apply to its routes
1476
1369
  * exactly as they did standalone, so a group wires its own plugins. The group's request-level
1477
1370
  * hooks (`onRequest`/`onResponse`/`onResponseFinalized`) are appended to this server's. This
@@ -1486,7 +1379,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1486
1379
  if (source.wsRouteCount > 0) {
1487
1380
  throw new RouteConfigError(
1488
1381
  "INVALID_PATH",
1489
- "merge() does not carry WebSocket routes — register .ws() routes on the parent server",
1382
+ "merge() does not carry WebSocket routes - register .ws() routes on the parent server",
1490
1383
  )
1491
1384
  }
1492
1385
  this.catalog.addBatch(source.catalog.entries().map((route) => this.bindFusedRuntime(route)))
@@ -1495,6 +1388,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1495
1388
  // when the parent has none so merging cannot silently disable a safety lane. If the parent already
1496
1389
  // has a runtime, either implementation can execute every resolved entry because route-specific
1497
1390
  // options were pinned during registration.
1391
+ this.responseContractRuntime ??= source.responseContractRuntime
1498
1392
  this.idempotencyRuntime ??= source.idempotencyRuntime
1499
1393
  this.effectLedgerRuntime ??= source.effectLedgerRuntime
1500
1394
  this.mcpRuntime ??= source.mcpRuntime
@@ -1547,12 +1441,12 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1547
1441
  }
1548
1442
 
1549
1443
  /**
1550
- * Resolve a `Request` to a `Response` — the whole lifecycle, testable without a port. The
1444
+ * Resolve a `Request` to a `Response` - the whole lifecycle, testable without a port. The
1551
1445
  * optional `platform` carries edge inputs (`env`, `waitUntil`); edge adapters pass it, and
1552
1446
  * Bun/Node/Deno omit it (then `c.env` is `undefined` and `c.waitUntil` runs fire-and-forget).
1553
1447
  */
1554
1448
  fetch(req: Request, platform?: Platform<EnvOf<Ctx>>): MaybePromise<Response> {
1555
- // A real `Request` satisfies `RequestSource`, so it's passed straight through — no per-request
1449
+ // A real `Request` satisfies `RequestSource`, so it's passed straight through - no per-request
1556
1450
  // wrapper allocation on the Web/Bun hot path.
1557
1451
  return this.fetchSource(req, platform)
1558
1452
  }
@@ -1561,7 +1455,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1561
1455
  source: RequestSource,
1562
1456
  platform?: Platform<EnvOf<Ctx>>,
1563
1457
  ): MaybePromise<Response> {
1564
- // Off path (default): straight through — one property check, no closure, no promise.
1458
+ // Off path (default): straight through - one property check, no closure, no promise.
1565
1459
  if (this.capacityGate === undefined) return this.fetchSourceInner(source, platform)
1566
1460
  return this.admitGated(requestOf(source), () => this.fetchSourceInner(source, platform))
1567
1461
  }
@@ -1585,7 +1479,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1585
1479
  if (this.onResponseHooks.length === 0 && this.onResponseFinalizedHooks.length === 0) {
1586
1480
  return outcome
1587
1481
  }
1588
- // onResponse sees every response — success, validation error, 404/405, timeout, onRequest
1482
+ // onResponse sees every response - success, validation error, 404/405, timeout, onRequest
1589
1483
  // short-circuit; normalize to a promise, then thread through the hooks.
1590
1484
  return outcome instanceof Promise
1591
1485
  ? outcome.then((response) =>
@@ -1632,7 +1526,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1632
1526
  /**
1633
1527
  * Run `produce` under the capacity gate: admit → run → release exactly once when the response is
1634
1528
  * produced (or the run throws). Only reached when {@link capacityGate} is set, so the off path pays
1635
- * nothing. The slot is held for the duration of handler execution, not the streaming of the body —
1529
+ * nothing. The slot is held for the duration of handler execution, not the streaming of the body -
1636
1530
  * capacity here bounds concurrent *work*, matching how in-flight is counted.
1637
1531
  */
1638
1532
  private admitGated(req: Request, produce: () => MaybePromise<Response>): MaybePromise<Response> {
@@ -1660,7 +1554,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1660
1554
  release()
1661
1555
  throw error
1662
1556
  }
1663
- // Release the slot once the response settles — on resolve OR reject — via `finally`, which passes
1557
+ // Release the slot once the response settles - on resolve OR reject - via `finally`, which passes
1664
1558
  // the value/rejection through unchanged. (A single settle hook, rather than separate then-arms: the
1665
1559
  // request pipeline resolves handler errors to a Response, so a rejection arm would be unreachable.)
1666
1560
  if (outcome instanceof Promise) return outcome.finally(release)
@@ -1669,11 +1563,11 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1669
1563
  }
1670
1564
 
1671
1565
  /**
1672
- * Like {@link fetch}, but renders a plain-data result **without** building a Web `Response` — the
1566
+ * Like {@link fetch}, but renders a plain-data result **without** building a Web `Response` - the
1673
1567
  * `@nifrajs/node` adapter serializes the returned primitives straight to the socket, skipping the undici
1674
1568
  * `Response` build + body drain (the bulk of the Node bridge cost, measured ≈4µs/req). A handler that
1675
1569
  * returns a `Response`, an error/short-circuit, or any registered `onResponse` hook falls back to the
1676
- * full Web path (`{ kind: "response" }`), so behavior is identical — only the common JSON-data case is
1570
+ * full Web path (`{ kind: "response" }`), so behavior is identical - only the common JSON-data case is
1677
1571
  * faster. Same lifecycle as {@link fetch} (body cap, validation, hooks all run); only the final
1678
1572
  * render differs.
1679
1573
  */
@@ -1686,7 +1580,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1686
1580
  platform?: Platform<EnvOf<Ctx>>,
1687
1581
  suppliedRuntime?: NodeOutcomeRuntime,
1688
1582
  ): MaybePromise<NodeServeOutcome> {
1689
- // onResponse hooks transform a Response, and the capacity gate wraps the Web response path — both
1583
+ // onResponse hooks transform a Response, and the capacity gate wraps the Web response path - both
1690
1584
  // force the Web path here (the gated `fetchSource` admits/sheds/releases); wrap its result.
1691
1585
  if (
1692
1586
  this.onResponseHooks.length > 0 ||
@@ -1719,7 +1613,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1719
1613
  }
1720
1614
 
1721
1615
  /**
1722
- * Resolve a WebSocket upgrade — the seam every serving adapter uses. Returns `pass` (not a WS
1616
+ * Resolve a WebSocket upgrade - the seam every serving adapter uses. Returns `pass` (not a WS
1723
1617
  * upgrade for a registered route → handle as normal HTTP), `reject` (a WS route matched but
1724
1618
  * `upgrade()` rejected, or the path was malformed → return `response`), or `upgrade` (perform the
1725
1619
  * runtime upgrade, then dispatch the native socket's events to `handler`, seeding `ws.data` with
@@ -1754,7 +1648,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1754
1648
  : origin !== null && handler.allowedOrigins.includes(origin)
1755
1649
  if (!allowed) return { kind: "reject", response: jsonError(403, "forbidden_origin") }
1756
1650
  } else if (origin !== null && !wsSameOrigin(origin, req)) {
1757
- // Secure default (no explicit `allowedOrigins`): reject a CROSS-ORIGIN browser handshake — the
1651
+ // Secure default (no explicit `allowedOrigins`): reject a CROSS-ORIGIN browser handshake - the
1758
1652
  // CSWSH case, since browsers send cookies on WS handshakes and don't apply CORS. Non-browser
1759
1653
  // clients send no `Origin` and pass; same-origin browsers pass. Set `allowedOrigins` to permit
1760
1654
  // specific cross-origin clients (or `() => true` for a genuinely public socket).
@@ -1814,7 +1708,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1814
1708
  * result + `set` into the output `T` (`toResponse` → a Web `Response`; `toNodeOutcome` → node-direct
1815
1709
  * primitives), `wrapResponse` lifts an early/error `Response` into that same `T`, and `onTimeout`
1816
1710
  * produces the 503. The Web `fetch` and `resolveNode` are thin callers over this one routing +
1817
- * context + lifecycle implementation — no duplication across the trust boundary.
1711
+ * context + lifecycle implementation - no duplication across the trust boundary.
1818
1712
  */
1819
1713
  /** Apply the `clientIp` trust declaration to the adapter's raw socket peer, returning a platform
1820
1714
  * whose `clientIp` is the derived caller. Only called when a trust declaration is configured. */
@@ -1832,7 +1726,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1832
1726
  finalize: (result: unknown, set: CtxSet) => T,
1833
1727
  wrapResponse: (response: Response) => T,
1834
1728
  onTimeout: () => T,
1835
- // True only from the Web `fetch` path — unlocks each route's fused lane, whose output type IS
1729
+ // True only from the Web `fetch` path - unlocks each route's fused lane, whose output type IS
1836
1730
  // `Response` (`T = Response` there by construction; the node path always passes false).
1837
1731
  webFast: boolean,
1838
1732
  ): MaybePromise<T> {
@@ -1851,7 +1745,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1851
1745
 
1852
1746
  /**
1853
1747
  * onRequest short-circuit path. Synchronous as long as every hook returns synchronously (the
1854
- * common case — e.g. CORS returning `undefined` for a non-preflight request): an `async` version
1748
+ * common case - e.g. CORS returning `undefined` for a non-preflight request): an `async` version
1855
1749
  * here put EVERY request of any app with one onRequest hook onto the promise machinery, profiled
1856
1750
  * at ~13% of a realistic request. The first hook that returns a Promise hands the REMAINING
1857
1751
  * hooks to the async continuation; behavior is identical.
@@ -1895,7 +1789,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1895
1789
  }
1896
1790
 
1897
1791
  /** Async tail of {@link runWithOnRequest}: applies the first awaited hook's outcome, then runs
1898
- * the remaining hooks (awaiting freely — we're already async here). */
1792
+ * the remaining hooks (awaiting freely - we're already async here). */
1899
1793
  private async continueOnRequest<T>(
1900
1794
  first: OnRequestResult,
1901
1795
  nextIndex: number,
@@ -1990,7 +1884,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1990
1884
  webFast: boolean,
1991
1885
  ): MaybePromise<T> {
1992
1886
  // An idempotency route runs its dedupe lane first; on a fresh key it delegates to the normal lanes
1993
- // (with the body buffered). All non-idempotent routes skip straight to the lanes — no added cost.
1887
+ // (with the body buffered). All non-idempotent routes skip straight to the lanes - no added cost.
1994
1888
  // The runtime is always present when a route resolved idempotency (enforced at registration).
1995
1889
  if (entry.idempotent !== undefined && this.idempotencyRuntime !== undefined) {
1996
1890
  return this.idempotencyRuntime.run(
@@ -2091,7 +1985,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2091
1985
  finalize,
2092
1986
  wrapResponse,
2093
1987
  )
2094
- // The request timeout only bounds work that is actually pending — a synchronous (bare) result is
1988
+ // The request timeout only bounds work that is actually pending - a synchronous (bare) result is
2095
1989
  // already complete and can't time out, so it's returned as-is (no 503 race, no promise).
2096
1990
  if (controller !== undefined && outcome instanceof Promise) {
2097
1991
  const timedOut =
@@ -2187,11 +2081,11 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2187
2081
 
2188
2082
  /**
2189
2083
  * The synchronous fast path selected by a route's execution plan: apply static decorations, call the
2190
- * handler, render the result — **no `await`** unless the handler itself returns a promise. It mirrors
2084
+ * handler, render the result - **no `await`** unless the handler itself returns a promise. It mirrors
2191
2085
  * the bare slice of {@link runLifecycle} (which a bare route would otherwise no-op through) and shares
2192
2086
  * {@link logRequestError}; a bare route has no `onError` hooks, so error handling is fully synchronous
2193
2087
  * (a thrown `Response` is control flow; anything else is a logged flat 500). This is where nifra skips
2194
- * the per-request async-frame tax — the same win codegen routers get, but without `eval`.
2088
+ * the per-request async-frame tax - the same win codegen routers get, but without `eval`.
2195
2089
  */
2196
2090
  private runBare<T>(
2197
2091
  entry: RouteEntry,
@@ -2216,12 +2110,12 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2216
2110
  return finalize(result, responseSet(ctx))
2217
2111
  }
2218
2112
 
2219
- /** Bare-route error rendering — identical to {@link runLifecycle}'s catch minus the (absent) onError
2113
+ /** Bare-route error rendering - identical to {@link runLifecycle}'s catch minus the (absent) onError
2220
2114
  * loop: a thrown `Response` is returned as deliberate control flow; anything else is logged + 500. */
2221
2115
  /**
2222
2116
  * Build a route's fused Web renderer. Composition happens once at
2223
2117
  * registration; the returned closure is what every request to the route runs. Behavior is
2224
- * byte-identical to the generic `runBare`/`runContextlessBare` + `toResponse` pair — same
2118
+ * byte-identical to the generic `runBare`/`runContextlessBare` + `toResponse` pair - same
2225
2119
  * decoration order, same error routing (thrown `Response` = control flow; anything else logs and
2226
2120
  * 500s), same respond semantics (the lifecycle parity suite pins it).
2227
2121
  */
@@ -2236,7 +2130,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2236
2130
  return jsonError(500, "internal_error")
2237
2131
  }
2238
2132
  if (contextless && decorations === undefined) {
2239
- // `() => ...` can't observe the context — skip allocating one entirely (errors still build
2133
+ // `() => ...` can't observe the context - skip allocating one entirely (errors still build
2240
2134
  // one for the structured log, exactly like runContextlessBare).
2241
2135
  const contextlessHandler = handler as unknown as ContextlessHandler
2242
2136
  return (source, params, search, signal, budget, platform, nativeContext) => {
@@ -2365,10 +2259,10 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2365
2259
  }
2366
2260
 
2367
2261
  // Inline fast path (profiled): a framed, in-cap, non-chunked body parses with the native
2368
- // `req.json()` directly — one promise, one `.then` closure per request. The generic
2262
+ // `req.json()` directly - one promise, one `.then` closure per request. The generic
2369
2263
  // `readBoundedJson` (an extra async-fn frame + per-request `finish`/`applyValidation`
2370
2264
  // closures) is kept for the chunked / length-less / oversized cases it exists for.
2371
- // Semantics are identical to readBoundedJsonSource — same checks, same error codes.
2265
+ // Semantics are identical to readBoundedJsonSource - same checks, same error codes.
2372
2266
  const declared = headerOf(source, "content-length")
2373
2267
  if (declared !== null) {
2374
2268
  const length = parseContentLength(declared)
@@ -2401,7 +2295,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2401
2295
  }
2402
2296
  }
2403
2297
 
2404
- /** Validate + run the handler for the bodyOnly path — shared by the inline fast path and the
2298
+ /** Validate + run the handler for the bodyOnly path - shared by the inline fast path and the
2405
2299
  * streaming fallback. A method (not per-request closures) so the hot path allocates nothing
2406
2300
  * beyond the one `.then` continuation. */
2407
2301
  private finishBodyOnly<T>(
@@ -2555,7 +2449,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2555
2449
  wrapResponse: (response: Response) => T,
2556
2450
  ): MaybePromise<T> {
2557
2451
  try {
2558
- // Call the validator directly for the raw StandardResult (read `.issues`/`.value`) — skip
2452
+ // Call the validator directly for the raw StandardResult (read `.issues`/`.value`) - skip
2559
2453
  // `validateStandard`'s per-request wrapper-object allocation, mirroring the bodyOnly path.
2560
2454
  const validation = entry.schema!.query!["~standard"].validate(
2561
2455
  queryObjectOf(ctx[CONTEXT_SEARCH]),
@@ -2627,7 +2521,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2627
2521
 
2628
2522
  /**
2629
2523
  * Thread the response through each global `onResponse` hook. Stays SYNCHRONOUS until a hook
2630
- * actually returns a Promise — an `async` version forced a promise + microtask on EVERY response
2524
+ * actually returns a Promise - an `async` version forced a promise + microtask on EVERY response
2631
2525
  * of any app with an onResponse hook (cors/securityHeaders/etag/timing all use onResponse), the
2632
2526
  * same ~13%/req tax the onRequest walk was de-async'd to avoid. The first async hook hands the
2633
2527
  * rest to {@link continueOnResponse}.
@@ -2722,7 +2616,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2722
2616
  /**
2723
2617
  * Bound the response time. On timeout we abort `ctx.signal` (so cancellation-aware
2724
2618
  * handlers can bail) and return 503; the in-flight work keeps running but its
2725
- * result is discarded — JS can't forcibly cancel a promise.
2619
+ * result is discarded - JS can't forcibly cancel a promise.
2726
2620
  */
2727
2621
  private async withTimeout<T>(
2728
2622
  work: Promise<T>,
@@ -2783,7 +2677,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2783
2677
  }
2784
2678
 
2785
2679
  // Context extensions: static decorations, then per-request derives.
2786
- // Each hook below only awaits when it actually returns a Promise — a sync hook
2680
+ // Each hook below only awaits when it actually returns a Promise - a sync hook
2787
2681
  // skips the microtask tick (a fast-path; ~64 ns/hook).
2788
2682
  if (entry.hasDecorations) Object.assign(ctx, entry.decorations) // skip the no-op on bare routes
2789
2683
  // The `.length` guards skip iterator setup for the common no-hook route (most
@@ -2816,6 +2710,36 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2816
2710
  }
2817
2711
  }
2818
2712
 
2713
+ // The response contract is checked LAST, after afterHandle, so it holds what actually ships
2714
+ // rather than what the handler happened to return. Only reached by a contracted route: the
2715
+ // registration decision above kept those off the fused/native lanes for exactly this step.
2716
+ const contract = entry.responseContract
2717
+ if (contract !== undefined) {
2718
+ const checked = contract.runtime.check(contract.schema, result)
2719
+ const outcome = checked instanceof Promise ? await checked : checked
2720
+ if (outcome.kind === "violation") {
2721
+ // The handler broke a contract it declared itself, so this is a server fault, not the
2722
+ // caller's. Fail closed: the undeclared payload is never sent, and the detail goes to the
2723
+ // (redacting) logger rather than to the client.
2724
+ this.logger.error("response contract violation", {
2725
+ method: ctx.req.method,
2726
+ path: pathnameOf(ctx.req.url),
2727
+ // `detail`, not `message`: the logger uses `message` for its own first argument, so a
2728
+ // field of that name is silently overwritten and the diagnosis disappears.
2729
+ detail: outcome.message,
2730
+ })
2731
+ return wrapResponse(jsonError(500, "internal_error"))
2732
+ }
2733
+ if (outcome.kind === "warn") {
2734
+ this.logger.warn("response contract", {
2735
+ method: ctx.req.method,
2736
+ path: pathnameOf(ctx.req.url),
2737
+ detail: outcome.message,
2738
+ })
2739
+ }
2740
+ result = outcome.value
2741
+ }
2742
+
2819
2743
  return finalize(result, responseSet(ctx))
2820
2744
  } catch (err) {
2821
2745
  return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse)
@@ -2829,7 +2753,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2829
2753
  finalize: (result: unknown, set: CtxSet) => T,
2830
2754
  wrapResponse: (response: Response) => T,
2831
2755
  ): Promise<T> {
2832
- // A *thrown* Response is deliberate control flow, not an error — a guard throws a redirect/401,
2756
+ // A *thrown* Response is deliberate control flow, not an error - a guard throws a redirect/401,
2833
2757
  // an action throws an error page. Return it as-is (Remix/SvelteKit semantics); don't run onError
2834
2758
  // or log it as a 500. This is what makes `throw redirect(...)` / `requireSession(...)` work from
2835
2759
  // any handler or loader.
@@ -2846,7 +2770,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2846
2770
  return wrapResponse(jsonError(500, "internal_error"))
2847
2771
  }
2848
2772
 
2849
- /** Log an unhandled request error to the (redacting) logger — shared by {@link runLifecycle} and the
2773
+ /** Log an unhandled request error to the (redacting) logger - shared by {@link runLifecycle} and the
2850
2774
  * bare fast path ({@link bareError}) so both record the same fields. Never throws; never leaks. */
2851
2775
  private logRequestError(err: unknown, ctx: RawContext): void {
2852
2776
  this.logger.error("unhandled request error", {
@@ -2874,7 +2798,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2874
2798
  if (form instanceof Response) return form
2875
2799
  parsed = form
2876
2800
  } else {
2877
- // multipart/form-data (file uploads) stays 415 on the schema path by design — files don't
2801
+ // multipart/form-data (file uploads) stays 415 on the schema path by design - files don't
2878
2802
  // fit a value schema; use a schema-less route + @nifrajs/uploads helpers for those.
2879
2803
  return jsonError(415, "unsupported_media_type")
2880
2804
  }
@@ -2922,12 +2846,12 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2922
2846
  /**
2923
2847
  * Read the body as text, capped at `maxBodyBytes`. Rejects (`null`) on a
2924
2848
  * `Content-Length` over the cap *before* buffering, and aborts mid-stream once the
2925
- * running byte count exceeds it — so a lying or absent length can't force us to
2849
+ * running byte count exceeds it - so a lying or absent length can't force us to
2926
2850
  * buffer an oversized payload.
2927
2851
  *
2928
2852
  * Fast path: when a non-chunked request carries a `Content-Length` within the cap,
2929
- * a native `req.json()` is already bounded — under HTTP/1.1 + HTTP/2 framing the
2930
- * runtime delivers at most `Content-Length` bytes — so we skip the manual stream
2853
+ * a native `req.json()` is already bounded - under HTTP/1.1 + HTTP/2 framing the
2854
+ * runtime delivers at most `Content-Length` bytes - so we skip the manual stream
2931
2855
  * loop and a separate text decode. It trusts the wire
2932
2856
  * *framing*, not the header value: nifra only ever receives framed Requests from the
2933
2857
  * runtime's HTTP server, never a hand-built one with a mismatched length. Chunked or
@@ -2992,7 +2916,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2992
2916
  * capture semantics; static and `:param` routes take the native lane. */
2993
2917
  private buildBunNativeRoutes(): BunNativeRoutes | undefined {
2994
2918
  // A `clientIp` trust declaration must run the resolver in `dispatch`, which the fused native lane
2995
- // bypasses — so an app that declares trust routes through the fetch lane (where `c.clientIp`
2919
+ // bypasses - so an app that declares trust routes through the fetch lane (where `c.clientIp`
2996
2920
  // resolves) instead of Bun's native table. The allocation-free default keeps native fusion.
2997
2921
  if (
2998
2922
  this.onRequestHooks.length > 0 ||
@@ -3038,7 +2962,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3038
2962
  * Start a `Bun.serve` instance bound to `port` (use `0` for an ephemeral port).
3039
2963
  *
3040
2964
  * `reusePort` sets `SO_REUSEPORT` so **multiple processes can bind the same port** and the kernel
3041
- * load-balances connections across them — the standard way to use every core (Bun is
2965
+ * load-balances connections across them - the standard way to use every core (Bun is
3042
2966
  * single-threaded per process). Spawn one process per core, each calling
3043
2967
  * `app.listen(PORT, { reusePort: true })`; see `examples/cluster.ts`. Every process must opt in,
3044
2968
  * and all of them must be the same app. Linux balances ~evenly; macOS accepts the flag but may
@@ -3064,14 +2988,14 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3064
2988
  }
3065
2989
  // Bun's `Server` is the concrete handle; we expose the stable `RunningServer`
3066
2990
  // subset so the public types don't depend on the ambient `Bun` global. The cast
3067
- // bridges them — Bun's `.port` is `number | undefined` (undefined only for unix
2991
+ // bridges them - Bun's `.port` is `number | undefined` (undefined only for unix
3068
2992
  // sockets, never a TCP `listen`) and its `.stop` returns a promise we don't await.
3069
- // Pass only the request — Bun's `fetch` 2nd arg is the Bun `Server`, not our `platform`.
2993
+ // Pass only the request - Bun's `fetch` 2nd arg is the Bun `Server`, not our `platform`.
3070
2994
  // With WS routes, hand Bun a `websocket` config + a fetch that upgrades matching requests (the
3071
2995
  // `server` 2nd arg is how Bun exposes `upgrade`); otherwise the lean request-only fetch. The
3072
- // `websocket` handlers are one shared dispatcher — each connection's `ws.data.handler` is the
2996
+ // `websocket` handlers are one shared dispatcher - each connection's `ws.data.handler` is the
3073
2997
  // matched route's handler, set by `server.upgrade`.
3074
- // With WS routes, the dispatcher comes from the installed `.use(websocket())` runtime — non-null
2998
+ // With WS routes, the dispatcher comes from the installed `.use(websocket())` runtime - non-null
3075
2999
  // because wsRouteCount > 0 means ws() ran, and ws() requires the runtime at registration.
3076
3000
  const wsHandlers =
3077
3001
  this.wsRouteCount === 0
@@ -3116,7 +3040,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3116
3040
 
3117
3041
  /**
3118
3042
  * Gracefully stop: wait for in-flight requests to finish (up to `drainMs`), then
3119
- * issue a single terminal stop — graceful if everything drained, forced if
3043
+ * issue a single terminal stop - graceful if everything drained, forced if
3120
3044
  * stragglers remain. Safe to call when not listening.
3121
3045
  *
3122
3046
  * The Bun semantics: poll `pendingRequests` (awaiting
@@ -3137,7 +3061,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3137
3061
  }
3138
3062
 
3139
3063
  private installSignalHandlers(): void {
3140
- // Drain, then let the process exit naturally — the stopped server no longer
3064
+ // Drain, then let the process exit naturally - the stopped server no longer
3141
3065
  // holds the event loop open. Opt-in (`gracefulSignals`), so taking over the
3142
3066
  // signals is consented; we don't force `process.exit`.
3143
3067
  const onSignal = (): void => {
@@ -3149,7 +3073,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3149
3073
  }
3150
3074
 
3151
3075
  /**
3152
- * Create a new {@link Server}. Pass an `Env` to type the platform bindings — `server<Env>()` makes
3076
+ * Create a new {@link Server}. Pass an `Env` to type the platform bindings - `server<Env>()` makes
3153
3077
  * `c.env: Env` in every handler + middleware, and types the `env` argument of `app.fetch` /
3154
3078
  * `toFetchHandler`. Omit it and `c.env` is `unknown` (validate/cast before use).
3155
3079
  */
@@ -3157,138 +3081,7 @@ export function server<Env = unknown>(
3157
3081
  options?: ServerOptions,
3158
3082
  ): Server<EmptyRegistry, { readonly env: Env }> {
3159
3083
  // `Env` is a phantom type-level marker: the runtime `env` arrives via `app.fetch(req, { env })` at
3160
- // request time, not stored on the builder — so seed the context type with a cast (as `derive`/
3084
+ // request time, not stored on the builder - so seed the context type with a cast (as `derive`/
3161
3085
  // `decorate` do for their `Ctx` extensions).
3162
3086
  return new Server(options) as unknown as Server<EmptyRegistry, { readonly env: Env }>
3163
3087
  }
3164
-
3165
- /** A Cloudflare Workers-style execution context (the `fetch` 3rd arg). Structural — only
3166
- * `waitUntil` is used; declared here so `@nifrajs/core` needs no Workers type dependency. */
3167
- export interface ExecutionContext {
3168
- waitUntil(promise: Promise<unknown>): void
3169
- }
3170
-
3171
- /** A Cloudflare Workers-style scheduled (cron) controller. Structural — no Workers type dependency. */
3172
- export interface ScheduledController {
3173
- /** Epoch ms the run was scheduled for. */
3174
- readonly scheduledTime: number
3175
- /** The matching cron expression from `wrangler.toml` `[triggers]`. */
3176
- readonly cron: string
3177
- /** Tell the platform not to retry this run on failure. */
3178
- noRetry(): void
3179
- }
3180
-
3181
- /** A nifra cron handler: the platform controller + the same typed `env`/`waitUntil` nifra threads into
3182
- * request handlers. Schedule background work with `waitUntil` so it outlives the trigger. */
3183
- export type ScheduledHandler<Env = unknown> = (
3184
- controller: ScheduledController,
3185
- context: { readonly env: Env; waitUntil(promise: Promise<unknown>): void },
3186
- ) => MaybePromise<void>
3187
-
3188
- /**
3189
- * Adapt a nifra app to an edge "ExportedHandler" — use it as a Cloudflare Workers (or any
3190
- * `fetch(request, env, ctx)` runtime) default export. It threads `env` + `ctx.waitUntil` into the
3191
- * nifra Context, so handlers read `c.env` and schedule background work via `c.waitUntil`:
3192
- *
3193
- * export default toFetchHandler(app)
3194
- *
3195
- * Pass `{ scheduled }` to also export a Workers cron handler (for a `[triggers]` schedule) — it
3196
- * receives the platform controller plus the same typed `env`/`waitUntil`:
3197
- *
3198
- * export default toFetchHandler(app, {
3199
- * scheduled: (controller, { env, waitUntil }) =>
3200
- * waitUntil(env.KV.put("last-run", String(controller.scheduledTime))),
3201
- * })
3202
- *
3203
- * No Workers-only deps — `app.fetch` stays a portable Web-standard handler; this is the thin
3204
- * shim from the platform's 3-arg `fetch`/`scheduled` to it.
3205
- */
3206
- /** Cloudflare's `WebSocketPair` — feature-detected (absent off Workers). Yields `{ 0: client, 1: server }`. */
3207
- type WebSocketPairCtor = new () => {
3208
- readonly 0: unknown
3209
- readonly 1: StandardWebSocket & { accept(): void }
3210
- }
3211
-
3212
- /** Structural view of a Cloudflare Durable Object namespace binding — keeps `@cloudflare/workers-types`
3213
- * out of `@nifrajs/core`. The real `DurableObjectNamespace` satisfies it. */
3214
- export interface DurableObjectNamespaceLike {
3215
- idFromName(name: string): unknown
3216
- get(id: unknown): { fetch(request: Request): Promise<Response> }
3217
- }
3218
-
3219
- /** The single hub Durable Object id nifra routes WS upgrades to (one hub per app — see `@nifrajs/workers`). */
3220
- const NIFRA_WS_HUB_ID = "nifra-ws-hub"
3221
-
3222
- export function toFetchHandler<Env = unknown>(
3223
- app: {
3224
- fetch(request: Request, platform?: Platform<Env>): MaybePromise<Response>
3225
- resolveWebSocketUpgrade?(
3226
- request: Request,
3227
- platform?: Platform<Env>,
3228
- ): MaybePromise<WebSocketUpgradeOutcome>
3229
- },
3230
- options?: {
3231
- scheduled?: ScheduledHandler<Env>
3232
- /**
3233
- * Route WebSocket upgrades to a Durable Object that holds the connections and runs the app's
3234
- * pub/sub — enabling cross-connection broadcast (`app.publish`) on Cloudflare Workers, where a
3235
- * stateless isolate can't. Pass the DO namespace binding from `env`; pair with `@nifrajs/workers`'
3236
- * `createWebSocketHub(app)` (the DO class) + a `wrangler.toml` binding. Without this, WS upgrades use
3237
- * a per-connection `WebSocketPair` (no broadcast).
3238
- */
3239
- webSocketHub?: (env: Env) => DurableObjectNamespaceLike
3240
- },
3241
- ): {
3242
- fetch(request: Request, env: Env, ctx: ExecutionContext): MaybePromise<Response>
3243
- scheduled?(controller: ScheduledController, env: Env, ctx: ExecutionContext): MaybePromise<void>
3244
- } {
3245
- const scheduled = options?.scheduled
3246
- const webSocketHub = options?.webSocketHub
3247
- const resolveWs = app.resolveWebSocketUpgrade?.bind(app)
3248
- return {
3249
- fetch: (request, env, ctx) => {
3250
- const platform: Platform<Env> = { env, waitUntil: (promise) => ctx.waitUntil(promise) }
3251
- // WebSocket broadcast on Workers: route upgrades to the hub Durable Object (it holds the
3252
- // connections + runs the app's pub/sub, so `app.publish` reaches every client). The hub itself
3253
- // resolves the route and rejects non-WS paths. See `@nifrajs/workers`' `createWebSocketHub`.
3254
- if (
3255
- webSocketHub !== undefined &&
3256
- request.headers.get("upgrade")?.toLowerCase() === "websocket"
3257
- ) {
3258
- const ns = webSocketHub(env)
3259
- return ns.get(ns.idFromName(NIFRA_WS_HUB_ID)).fetch(request)
3260
- }
3261
- // Workers WebSockets: a WS upgrade for a registered route becomes a `WebSocketPair` + a 101.
3262
- // Feature-detected, so non-Workers edge runtimes (which lack `WebSocketPair` — e.g. Deno Deploy
3263
- // uses `@nifrajs/deno`'s `Deno.upgradeWebSocket`) simply fall through to the normal `fetch`.
3264
- const Pair = (globalThis as { WebSocketPair?: WebSocketPairCtor }).WebSocketPair
3265
- if (resolveWs !== undefined && Pair !== undefined) {
3266
- const accept = (outcome: WebSocketUpgradeOutcome): MaybePromise<Response> => {
3267
- if (outcome.kind === "pass") return app.fetch(request, platform)
3268
- if (outcome.kind === "reject") return outcome.response
3269
- const pair = new Pair()
3270
- const server = pair[1]
3271
- server.accept()
3272
- // The outcome carries the installed runtime's `attach`, so `toFetchHandler` (a standalone
3273
- // function, no access to the app's private runtime) wires the socket without a static WS import.
3274
- outcome.attach(server, outcome.handler, outcome.data, {
3275
- openNow: true,
3276
- pubsub: outcome.pubsub,
3277
- })
3278
- // `webSocket` is a Workers-only `ResponseInit` field (absent from the standard type), and a
3279
- // 101 status is only valid on the Workers runtime — both gated by the `Pair` feature check.
3280
- return new Response(null, { status: 101, webSocket: pair[0] } as unknown as ResponseInit)
3281
- }
3282
- const out = resolveWs(request, platform)
3283
- return out instanceof Promise ? out.then(accept) : accept(out)
3284
- }
3285
- return app.fetch(request, platform)
3286
- },
3287
- ...(scheduled !== undefined
3288
- ? {
3289
- scheduled: (controller: ScheduledController, env: Env, ctx: ExecutionContext) =>
3290
- scheduled(controller, { env, waitUntil: (promise) => ctx.waitUntil(promise) }),
3291
- }
3292
- : {}),
3293
- }
3294
- }