@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
@@ -2,7 +2,9 @@ import { admitDeadline, createRequestBudget, createUnboundedRequestBudget, NIFRA
2
2
  import { FrameworkError, RouteConfigError } from "../errors.js";
3
3
  import { CAPABILITY_GUARD, createCapabilityGuard, DEFAULT_CAPABILITY_INTERCEPTOR_TIMEOUT_MS, normalizeRouteCapabilities, } from "../internal/capability-runtime.js";
4
4
  import { assuranceDeclarationsOf, assuranceEvidenceFor, NIFRA_ASSURANCE_IDS, validEvidenceId, } from "../internal/route-assurance.js";
5
- import { compileRoutePattern, decodeRouteParams, } from "../router/pattern.js";
5
+ import { RouteCatalog } from "../internal/route-catalog.js";
6
+ import { isSameOriginRequest } from "../internal/same-origin.js";
7
+ import { compileRoutePattern, decodeRouteParams } from "../router/pattern.js";
6
8
  import { EMPTY_PARAMS, Router } from "../router/router.js";
7
9
  import { parseContentLength } from "./body.js";
8
10
  import { resolveClientIp } from "./client-ip.js";
@@ -11,61 +13,10 @@ import { isUrlEncodedForm, queryObjectOf, readBoundedForm, searchOf, } from "./q
11
13
  import { RequestContext, readBoundedJsonSource } from "./request-context.js";
12
14
  import { fusedRespond, fusedRespondNoSet, toResponse } from "./respond.js";
13
15
  import { CONTEXT_SEARCH, CONTEXT_SET, EMPTY_RESPONSE_CONTROLS, getNeverAbortSignal, getUnboundedRequestBudget, headerOf, requestOf, } from "./runtime-core.js";
14
- import { INSTALL_EFFECT_LEDGER, INSTALL_IDEMPOTENCY, INSTALL_MCP, INSTALL_NODE_DIRECT, INSTALL_SSE, INSTALL_WS, } from "./install.js";
16
+ import { INSTALL_EFFECT_LEDGER, INSTALL_IDEMPOTENCY, INSTALL_MCP, INSTALL_NODE_DIRECT, INSTALL_RESPONSE_CONTRACT, INSTALL_SSE, INSTALL_WS, } from "./install.js";
15
17
  import { jsonLogger } from "./logger.js";
16
18
  const functionToString = Function.prototype.toString;
17
19
  const CONTEXTLESS_ARROW = /^(?:async\s*)?\(\s*\)\s*(?::[\s\S]*?)?=>/;
18
- /**
19
- * Runtime route catalog. Single-route registration mutates directly; multi-route registration replays
20
- * the existing catalog plus the candidate batch into a staged router, then swaps the complete state only
21
- * after every route validates. Failed `implement()`/`merge()` batches therefore leave matching and
22
- * reflection unchanged.
23
- */
24
- class RouteCatalog {
25
- matcher = new Router();
26
- records = [];
27
- /** Allocation-free reflection view for the common no-assurance case. Derived only at commit time. */
28
- descriptors = [];
29
- assurancePresent = false;
30
- add(route) {
31
- this.matcher.add(route.method, route.pattern, route.entry);
32
- this.records.push(route);
33
- this.descriptors.push(route.descriptor);
34
- if (route.assurance.length > 0)
35
- this.assurancePresent = true;
36
- }
37
- addBatch(routes) {
38
- if (routes.length === 0)
39
- return;
40
- const nextRecords = this.records.concat(routes);
41
- const nextDescriptors = this.descriptors.concat(routes.map(({ descriptor }) => descriptor));
42
- const nextAssurancePresent = this.assurancePresent || routes.some((route) => route.assurance.length > 0);
43
- const staged = new Router();
44
- for (const route of this.records)
45
- staged.add(route.method, route.pattern, route.entry);
46
- for (const route of routes)
47
- staged.add(route.method, route.pattern, route.entry);
48
- this.matcher = staged;
49
- this.records = nextRecords;
50
- this.descriptors = nextDescriptors;
51
- this.assurancePresent = nextAssurancePresent;
52
- }
53
- find(method, path) {
54
- return this.matcher.find(method, path);
55
- }
56
- entries() {
57
- return this.records;
58
- }
59
- routeDescriptors() {
60
- return this.descriptors;
61
- }
62
- lastDescriptor() {
63
- return this.records[this.records.length - 1]?.descriptor;
64
- }
65
- hasAssurance() {
66
- return this.assurancePresent;
67
- }
68
- }
69
20
  /** The socket peer Bun observed, as a `Platform` for the request lifecycle (`undefined` if unknown).
70
21
  * Typed structurally on `requestIP` alone so any Bun `Server` (WS or not) satisfies it. */
71
22
  function bunPeerPlatform(server, req) {
@@ -98,17 +49,10 @@ export { defineIdentityPlugin, definePlugin, defineRouterPlugin, } from "./plugi
98
49
  const DEFAULT_MAX_BODY_BYTES = 1_000_000;
99
50
  const DEFAULT_DRAIN_MS = 10_000;
100
51
  const DRAIN_POLL_MS = 10;
101
- /** Same-origin check for a WebSocket handshake (CSWSH default): the `Origin`'s host[:port] must equal the
102
- * request's own host (from `req.url`, which the runtime builds from the `Host` header). Scheme differs
103
- * (ws↔http), so compare host only. An unparseable Origin is treated as NOT same-origin (rejected). */
104
- function wsSameOrigin(origin, req) {
105
- try {
106
- return new URL(origin).host === new URL(req.url).host;
107
- }
108
- catch {
109
- return false;
110
- }
111
- }
52
+ /** Same-origin check for a WebSocket handshake (CSWSH default). {@link isSameOriginRequest} is the one
53
+ * owner, shared with the server-function mount in `@nifrajs/web` - the two used to answer differently
54
+ * for the same request, so a browser that could open a socket was told its POST was cross-origin. */
55
+ const wsSameOrigin = isSameOriginRequest;
112
56
  function validationError(issues) {
113
57
  const serialized = issues.map((issue) => {
114
58
  const path = issue.path?.map((seg) => String(typeof seg === "object" ? seg.key : seg));
@@ -159,12 +103,15 @@ export class Server {
159
103
  wsRouter;
160
104
  wsRouteCount;
161
105
  /** In-process pub/sub backing `ws.subscribe(topic)` + `app.publish(topic, data)` (single-instance).
162
- * Created by the first `app.ws()` via the `@nifrajs/core/ws` runtime — `undefined` until then, so a
106
+ * Created by the first `app.ws()` via the `@nifrajs/core/ws` runtime - `undefined` until then, so a
163
107
  * no-WebSocket app never constructs (or bundles) it. */
164
108
  topics;
165
109
  maxBodyBytes;
166
110
  wsMaxPayloadBytes;
167
111
  requestTimeoutMs;
112
+ /** Installed by the `responseContract()` plugin; `undefined` = not installed, which is the default
113
+ * and the state in which a declared `response` schema stays a compile-time contract only. */
114
+ responseContractRuntime;
168
115
  /** Opt-in caller-IP trust declaration; `undefined` = socket peer only, no forwarded header believed. */
169
116
  clientIpTrust;
170
117
  acceptInboundDeadlines;
@@ -205,7 +152,7 @@ export class Server {
205
152
  onResponseHooks;
206
153
  onResponseFinalizedHooks;
207
154
  responseRequests;
208
- /** Names of plugins/middleware already applied via `use` — for idempotent dedupe. */
155
+ /** Names of plugins/middleware already applied via `use` - for idempotent dedupe. */
209
156
  appliedPlugins;
210
157
  /** Order-scoped evidence captured by routes registered after an assured plugin. */
211
158
  activeAssurance;
@@ -245,6 +192,7 @@ export class Server {
245
192
  this.effectLedgerRuntime = undefined;
246
193
  // The idempotency runtime is installed by `.use(idempotency())`; a bare app never imports it, so the
247
194
  // dedupe machinery tree-shakes out. A route that declares idempotency without it is a build error.
195
+ this.responseContractRuntime = undefined;
248
196
  this.idempotencyRuntime = undefined;
249
197
  this.logger = options.logger ?? jsonLogger();
250
198
  this.defaultOnValidationError = options.onValidationError;
@@ -347,7 +295,7 @@ export class Server {
347
295
  this.onErrorHooks.push(fn);
348
296
  return this;
349
297
  }
350
- /** Transform every outgoing response — success, error, 404, 405, short-circuit. Global. */
298
+ /** Transform every outgoing response - success, error, 404, 405, short-circuit. Global. */
351
299
  onResponse(fn) {
352
300
  this.assertConfigurable("onResponse()");
353
301
  this.onResponseHooks.push(fn);
@@ -417,11 +365,11 @@ export class Server {
417
365
  return this.route("GET", path, schemaOrHandler, handler);
418
366
  }
419
367
  /**
420
- * Register a **typed SSE route** — a GET endpoint streaming `text/event-stream` whose event
368
+ * Register a **typed SSE route** - a GET endpoint streaming `text/event-stream` whose event
421
369
  * payloads are contracted by `schema.sse`. The handler receives the validated context plus a
422
370
  * {@link TypedSSEStream}: `stream.send(event)` is compile-time-checked against the schema and
423
371
  * JSON-serialized into the SSE `data:` field. The typed client sees the marker and grows a
424
- * `.subscribe(onEvent)` for the route with the same payload type — end-to-end typed streaming.
372
+ * `.subscribe(onEvent)` for the route with the same payload type - end-to-end typed streaming.
425
373
  *
426
374
  * import { streaming } from "@nifrajs/core/sse" // .use(streaming()) enables .sse()
427
375
  * const app = server().use(streaming()).sse("/feed", { sse: t.object({ id: t.integer(), title: t.string() }) },
@@ -457,7 +405,7 @@ export class Server {
457
405
  const plan = requireMcpRuntime(this.mcpRuntime).tool(name, config, handler);
458
406
  this.register("POST", plan.path, plan.schema, plan.run);
459
407
  // Tag the just-registered descriptor as an MCP tool. `tool` is readonly on RouteDescriptor (an
460
- // introspection field), so write it through a narrow mutable view — not `any`.
408
+ // introspection field), so write it through a narrow mutable view - not `any`.
461
409
  const lastRoute = this.catalog.lastDescriptor();
462
410
  if (lastRoute) {
463
411
  ;
@@ -466,7 +414,7 @@ export class Server {
466
414
  return this;
467
415
  }
468
416
  /**
469
- * Declare an MCP **resource** — read-only data an agent can fetch through `nifra mcp` (app config, a
417
+ * Declare an MCP **resource** - read-only data an agent can fetch through `nifra mcp` (app config, a
470
418
  * generated document, …). `read` runs in the app process, so capture whatever app state it needs in the
471
419
  * closure. `uri` is the MCP resource identifier (e.g. `"myapp://config"`). The sibling of {@link tool}
472
420
  * for the resource half of MCP.
@@ -477,7 +425,7 @@ export class Server {
477
425
  return this;
478
426
  }
479
427
  /**
480
- * Declare an MCP **prompt** — a reusable prompt template an agent can fetch through `nifra mcp`.
428
+ * Declare an MCP **prompt** - a reusable prompt template an agent can fetch through `nifra mcp`.
481
429
  * `handler` receives the caller's arguments and returns the rendered messages.
482
430
  */
483
431
  prompt(name, config, handler) {
@@ -485,11 +433,11 @@ export class Server {
485
433
  this.mcpPromptList.push(requireMcpRuntime(this.mcpRuntime).prompt(name, config, handler));
486
434
  return this;
487
435
  }
488
- /** The MCP resources declared via {@link resource} — enumerated by `nifra mcp`. */
436
+ /** The MCP resources declared via {@link resource} - enumerated by `nifra mcp`. */
489
437
  mcpResources() {
490
438
  return this.mcpResourceList;
491
439
  }
492
- /** The MCP prompts declared via {@link prompt} — enumerated by `nifra mcp`. */
440
+ /** The MCP prompts declared via {@link prompt} - enumerated by `nifra mcp`. */
493
441
  mcpPrompts() {
494
442
  return this.mcpPromptList;
495
443
  }
@@ -497,7 +445,7 @@ export class Server {
497
445
  * Register a **WebSocket** route. The connection upgrades on a `GET` to `path` carrying
498
446
  * `Upgrade: websocket`; the optional `handler.upgrade(c)` runs in the request context first and may
499
447
  * reject (return a `Response`) or seed per-connection `ws.data`. WebSockets are served by the
500
- * adapter (`listen()`, `@nifrajs/node`, `@nifrajs/deno`, `toFetchHandler`) — not by bare `app.fetch`, which
448
+ * adapter (`listen()`, `@nifrajs/node`, `@nifrajs/deno`, `toFetchHandler`) - not by bare `app.fetch`, which
501
449
  * has no socket (a WS path through `app.fetch` is a normal HTTP response).
502
450
  *
503
451
  * The route also enters the type-level registry (under the pseudo-method `"WS"`), so the typed
@@ -511,10 +459,10 @@ export class Server {
511
459
  ws(path, handler) {
512
460
  this.assertConfigurable("ws()");
513
461
  // Boot-time guard: the WS runtime is a subpath (`@nifrajs/core/ws`) so no-WebSocket apps don't
514
- // bundle it. Registration is the loud, early failure point — never the first connection.
462
+ // bundle it. Registration is the loud, early failure point - never the first connection.
515
463
  const runtime = requireWsRuntime(this.wsRuntime);
516
464
  this.topics ??= runtime.createTopics();
517
- // A `messageSchema` wraps `message` with validation once, here — every adapter then dispatches
465
+ // A `messageSchema` wraps `message` with validation once, here - every adapter then dispatches
518
466
  // already-validated, typed messages (Bun/Deno/Node/Workers) with no per-adapter code.
519
467
  this.wsRouter.add("GET", path, {
520
468
  handler: runtime.wrapHandler(handler),
@@ -524,11 +472,11 @@ export class Server {
524
472
  }
525
473
  /**
526
474
  * Broadcast `data` to every WebSocket connection subscribed to `topic` (via `ws.subscribe(topic)`).
527
- * In-process and **single-instance** (see {@link TopicRegistry}) — a multi-instance deploy must bridge
475
+ * In-process and **single-instance** (see {@link TopicRegistry}) - a multi-instance deploy must bridge
528
476
  * an external fan-out (Redis, a Durable Object) to this. A no-op when nobody is subscribed.
529
477
  */
530
478
  publish(topic, data) {
531
- // No `app.ws()` yet ⇒ no registry and necessarily no subscribers — a publish is a no-op anyway.
479
+ // No `app.ws()` yet ⇒ no registry and necessarily no subscribers - a publish is a no-op anyway.
532
480
  this.topics?.publish(topic, data);
533
481
  }
534
482
  route(method, path, schemaOrHandler, handler) {
@@ -549,7 +497,7 @@ export class Server {
549
497
  }
550
498
  /**
551
499
  * Low-level route registration shared by the inline builder and `implement()`.
552
- * Captures the server's current `derive`/`decorate` chain into the route — this
500
+ * Captures the server's current `derive`/`decorate` chain into the route - this
553
501
  * is the "compiled", order-scoped per-route chain.
554
502
  */
555
503
  register(method, path, schema, handler) {
@@ -578,7 +526,7 @@ export class Server {
578
526
  }
579
527
  const hasDecorations = Reflect.ownKeys(routeDecorations).length > 0;
580
528
  // An idempotency route runs a dedupe lane that must buffer the body and capture the response, so it
581
- // never takes the fused/native fast path — force it onto the portable matched lane (which routes
529
+ // never takes the fused/native fast path - force it onto the portable matched lane (which routes
582
530
  // through `fetchMatched`, where the dedupe wrapper lives). Fail closed: a route may not declare
583
531
  // idempotency unless the idempotency runtime is installed, so the safety gate can never be silently
584
532
  // dropped by a missing plugin.
@@ -588,13 +536,21 @@ export class Server {
588
536
  const idempotent = this.idempotencyRuntime?.resolve(schema, authenticated, this.maxBodyBytes);
589
537
  // A ledgered route (capabilities declared + `.use(effectLedger())`) needs a per-request
590
538
  // context to carry the ledger and a settle step to seal + sink it, so it too leaves the
591
- // fused/contextless fast path. Resolved per route, at registration — like the capability guard.
539
+ // fused/contextless fast path. Resolved per route, at registration - like the capability guard.
592
540
  const ledgered = this.effectLedgerRuntime?.resolve(capabilities, method, path);
541
+ // A checked response schema needs the handler's VALUE before it becomes bytes, and the fused and
542
+ // native lanes exist precisely to skip that step. So a contracted route leaves them - the same
543
+ // trade an idempotent route makes above, and the reason this is opt-in per server.
544
+ const runtime = this.responseContractRuntime;
545
+ const contracted = runtime !== undefined && schema?.response !== undefined
546
+ ? { runtime, schema: schema.response }
547
+ : undefined;
593
548
  const bare = schema?.params === undefined &&
594
549
  schema?.body === undefined &&
595
550
  schema?.query === undefined &&
596
551
  idempotent === undefined &&
597
552
  ledgered === undefined &&
553
+ contracted === undefined &&
598
554
  this.derives.length === 0 &&
599
555
  this.beforeHandleHooks.length === 0 &&
600
556
  this.afterHandleHooks.length === 0 &&
@@ -603,9 +559,12 @@ export class Server {
603
559
  ? this.buildFusedWeb(handler, hasDecorations ? routeDecorations : undefined, isContextlessNoArgArrow(handler))
604
560
  : undefined;
605
561
  const contextless = bare && this.aroundHooks.length === 0 && isContextlessNoArgArrow(handler);
562
+ // The body and query lanes finalize the handler's result themselves, so a contracted route takes
563
+ // the general lifecycle lane instead - one place owns the check rather than three.
606
564
  const lane = bare
607
565
  ? "bare"
608
- : schema?.body !== undefined &&
566
+ : contracted === undefined &&
567
+ schema?.body !== undefined &&
609
568
  schema.query === undefined &&
610
569
  schema.params === undefined &&
611
570
  this.derives.length === 0 &&
@@ -613,7 +572,8 @@ export class Server {
613
572
  this.afterHandleHooks.length === 0 &&
614
573
  this.onErrorHooks.length === 0
615
574
  ? "body"
616
- : schema?.body === undefined &&
575
+ : contracted === undefined &&
576
+ schema?.body === undefined &&
617
577
  schema?.query !== undefined &&
618
578
  schema.params === undefined &&
619
579
  this.derives.length === 0 &&
@@ -630,6 +590,7 @@ export class Server {
630
590
  schema,
631
591
  idempotent,
632
592
  ledgered,
593
+ responseContract: contracted,
633
594
  derives: [...this.derives],
634
595
  decorations: routeDecorations,
635
596
  hasDecorations,
@@ -730,6 +691,11 @@ export class Server {
730
691
  idempotencyRunLanes(buffered, platform, entry, params, search) {
731
692
  return Promise.resolve(this.runMatchedLanes(buffered, platform, entry, params, search, toResponse, IDENTITY_RESPONSE, RESPONSE_TIMEOUT, false));
732
693
  }
694
+ /** @internal Symbol-keyed install seam for the `responseContract()` plugin. Off the public typed surface. */
695
+ [INSTALL_RESPONSE_CONTRACT](runtime) {
696
+ this.assertConfigurable("responseContract()");
697
+ this.responseContractRuntime = runtime;
698
+ }
733
699
  /** @internal Symbol-keyed install seam for the `idempotency()` plugin. Off the public typed surface. */
734
700
  [INSTALL_IDEMPOTENCY](runtime) {
735
701
  this.assertConfigurable("idempotency()");
@@ -756,16 +722,16 @@ export class Server {
756
722
  this.wsRuntime = runtime;
757
723
  }
758
724
  /**
759
- * Merge another server's routes into this one — the composition escape hatch for large apps.
725
+ * Merge another server's routes into this one - the composition escape hatch for large apps.
760
726
  *
761
727
  * WHY: the fluent chain accumulates one type-alias level per route, and TypeScript resolves
762
- * that stack in one recursion — a single chain hits TS2589 at ~95 routes. Groups keep every
728
+ * that stack in one recursion - a single chain hits TS2589 at ~95 routes. Groups keep every
763
729
  * chain short: build each domain (`listings`, `agents`, …) as its own `server()` (its registry
764
- * resolves independently), then `app.merge(listings).merge(agents)` — each merge adds ONE level
730
+ * resolves independently), then `app.merge(listings).merge(agents)` - each merge adds ONE level
765
731
  * regardless of group size. 300+ routes stay fully typed (see many-routes.test-d.ts). The
766
732
  * other escape hatch is contract-first `implement()`, whose registry is a single object type.
767
733
  *
768
- * Semantics: merged routes keep the chains captured where they were DEFINED — the group's
734
+ * Semantics: merged routes keep the chains captured where they were DEFINED - the group's
769
735
  * `derive`/`decorate`/`beforeHandle`/`afterHandle`/`onError`/`around` apply to its routes
770
736
  * exactly as they did standalone, so a group wires its own plugins. The group's request-level
771
737
  * hooks (`onRequest`/`onResponse`/`onResponseFinalized`) are appended to this server's. This
@@ -778,7 +744,7 @@ export class Server {
778
744
  this.assertConfigurable("merge()");
779
745
  const source = other;
780
746
  if (source.wsRouteCount > 0) {
781
- throw new RouteConfigError("INVALID_PATH", "merge() does not carry WebSocket routes — register .ws() routes on the parent server");
747
+ throw new RouteConfigError("INVALID_PATH", "merge() does not carry WebSocket routes - register .ws() routes on the parent server");
782
748
  }
783
749
  this.catalog.addBatch(source.catalog.entries().map((route) => this.bindFusedRuntime(route)));
784
750
  // Resolved idempotency/ledger route entries carry their own store/sink configuration, while the
@@ -786,6 +752,7 @@ export class Server {
786
752
  // when the parent has none so merging cannot silently disable a safety lane. If the parent already
787
753
  // has a runtime, either implementation can execute every resolved entry because route-specific
788
754
  // options were pinned during registration.
755
+ this.responseContractRuntime ??= source.responseContractRuntime;
789
756
  this.idempotencyRuntime ??= source.idempotencyRuntime;
790
757
  this.effectLedgerRuntime ??= source.effectLedgerRuntime;
791
758
  this.mcpRuntime ??= source.mcpRuntime;
@@ -832,17 +799,17 @@ export class Server {
832
799
  });
833
800
  }
834
801
  /**
835
- * Resolve a `Request` to a `Response` — the whole lifecycle, testable without a port. The
802
+ * Resolve a `Request` to a `Response` - the whole lifecycle, testable without a port. The
836
803
  * optional `platform` carries edge inputs (`env`, `waitUntil`); edge adapters pass it, and
837
804
  * Bun/Node/Deno omit it (then `c.env` is `undefined` and `c.waitUntil` runs fire-and-forget).
838
805
  */
839
806
  fetch(req, platform) {
840
- // A real `Request` satisfies `RequestSource`, so it's passed straight through — no per-request
807
+ // A real `Request` satisfies `RequestSource`, so it's passed straight through - no per-request
841
808
  // wrapper allocation on the Web/Bun hot path.
842
809
  return this.fetchSource(req, platform);
843
810
  }
844
811
  fetchSource(source, platform) {
845
- // Off path (default): straight through — one property check, no closure, no promise.
812
+ // Off path (default): straight through - one property check, no closure, no promise.
846
813
  if (this.capacityGate === undefined)
847
814
  return this.fetchSourceInner(source, platform);
848
815
  return this.admitGated(requestOf(source), () => this.fetchSourceInner(source, platform));
@@ -856,7 +823,7 @@ export class Server {
856
823
  if (this.onResponseHooks.length === 0 && this.onResponseFinalizedHooks.length === 0) {
857
824
  return outcome;
858
825
  }
859
- // onResponse sees every response — success, validation error, 404/405, timeout, onRequest
826
+ // onResponse sees every response - success, validation error, 404/405, timeout, onRequest
860
827
  // short-circuit; normalize to a promise, then thread through the hooks.
861
828
  return outcome instanceof Promise
862
829
  ? outcome.then((response) => this.applyOnResponseAndFinalize(response, this.takeResponseRequest(source)))
@@ -881,7 +848,7 @@ export class Server {
881
848
  /**
882
849
  * Run `produce` under the capacity gate: admit → run → release exactly once when the response is
883
850
  * produced (or the run throws). Only reached when {@link capacityGate} is set, so the off path pays
884
- * nothing. The slot is held for the duration of handler execution, not the streaming of the body —
851
+ * nothing. The slot is held for the duration of handler execution, not the streaming of the body -
885
852
  * capacity here bounds concurrent *work*, matching how in-flight is counted.
886
853
  */
887
854
  admitGated(req, produce) {
@@ -908,7 +875,7 @@ export class Server {
908
875
  release();
909
876
  throw error;
910
877
  }
911
- // Release the slot once the response settles — on resolve OR reject — via `finally`, which passes
878
+ // Release the slot once the response settles - on resolve OR reject - via `finally`, which passes
912
879
  // the value/rejection through unchanged. (A single settle hook, rather than separate then-arms: the
913
880
  // request pipeline resolves handler errors to a Response, so a rejection arm would be unreachable.)
914
881
  if (outcome instanceof Promise)
@@ -917,11 +884,11 @@ export class Server {
917
884
  return outcome;
918
885
  }
919
886
  /**
920
- * Like {@link fetch}, but renders a plain-data result **without** building a Web `Response` — the
887
+ * Like {@link fetch}, but renders a plain-data result **without** building a Web `Response` - the
921
888
  * `@nifrajs/node` adapter serializes the returned primitives straight to the socket, skipping the undici
922
889
  * `Response` build + body drain (the bulk of the Node bridge cost, measured ≈4µs/req). A handler that
923
890
  * returns a `Response`, an error/short-circuit, or any registered `onResponse` hook falls back to the
924
- * full Web path (`{ kind: "response" }`), so behavior is identical — only the common JSON-data case is
891
+ * full Web path (`{ kind: "response" }`), so behavior is identical - only the common JSON-data case is
925
892
  * faster. Same lifecycle as {@link fetch} (body cap, validation, hooks all run); only the final
926
893
  * render differs.
927
894
  */
@@ -929,7 +896,7 @@ export class Server {
929
896
  return this.resolveNodeSource(req, platform);
930
897
  }
931
898
  resolveNodeSource(source, platform, suppliedRuntime) {
932
- // onResponse hooks transform a Response, and the capacity gate wraps the Web response path — both
899
+ // onResponse hooks transform a Response, and the capacity gate wraps the Web response path - both
933
900
  // force the Web path here (the gated `fetchSource` admits/sheds/releases); wrap its result.
934
901
  if (this.onResponseHooks.length > 0 ||
935
902
  this.onResponseFinalizedHooks.length > 0 ||
@@ -949,7 +916,7 @@ export class Server {
949
916
  return this.dispatch(source, platform, runtime.toOutcome, runtime.fromResponse, runtime.timeout, false);
950
917
  }
951
918
  /**
952
- * Resolve a WebSocket upgrade — the seam every serving adapter uses. Returns `pass` (not a WS
919
+ * Resolve a WebSocket upgrade - the seam every serving adapter uses. Returns `pass` (not a WS
953
920
  * upgrade for a registered route → handle as normal HTTP), `reject` (a WS route matched but
954
921
  * `upgrade()` rejected, or the path was malformed → return `response`), or `upgrade` (perform the
955
922
  * runtime upgrade, then dispatch the native socket's events to `handler`, seeding `ws.data` with
@@ -986,7 +953,7 @@ export class Server {
986
953
  return { kind: "reject", response: jsonError(403, "forbidden_origin") };
987
954
  }
988
955
  else if (origin !== null && !wsSameOrigin(origin, req)) {
989
- // Secure default (no explicit `allowedOrigins`): reject a CROSS-ORIGIN browser handshake — the
956
+ // Secure default (no explicit `allowedOrigins`): reject a CROSS-ORIGIN browser handshake - the
990
957
  // CSWSH case, since browsers send cookies on WS handshakes and don't apply CORS. Non-browser
991
958
  // clients send no `Origin` and pass; same-origin browsers pass. Set `allowedOrigins` to permit
992
959
  // specific cross-origin clients (or `() => true` for a genuinely public socket).
@@ -1034,7 +1001,7 @@ export class Server {
1034
1001
  * result + `set` into the output `T` (`toResponse` → a Web `Response`; `toNodeOutcome` → node-direct
1035
1002
  * primitives), `wrapResponse` lifts an early/error `Response` into that same `T`, and `onTimeout`
1036
1003
  * produces the 503. The Web `fetch` and `resolveNode` are thin callers over this one routing +
1037
- * context + lifecycle implementation — no duplication across the trust boundary.
1004
+ * context + lifecycle implementation - no duplication across the trust boundary.
1038
1005
  */
1039
1006
  /** Apply the `clientIp` trust declaration to the adapter's raw socket peer, returning a platform
1040
1007
  * whose `clientIp` is the derived caller. Only called when a trust declaration is configured. */
@@ -1043,7 +1010,7 @@ export class Server {
1043
1010
  return { ...platform, clientIp: derived };
1044
1011
  }
1045
1012
  dispatch(source, platform, finalize, wrapResponse, onTimeout,
1046
- // True only from the Web `fetch` path — unlocks each route's fused lane, whose output type IS
1013
+ // True only from the Web `fetch` path - unlocks each route's fused lane, whose output type IS
1047
1014
  // `Response` (`T = Response` there by construction; the node path always passes false).
1048
1015
  webFast) {
1049
1016
  // Resolve the trust declaration into the platform's `clientIp` ONCE, here at the shared funnel, so
@@ -1059,7 +1026,7 @@ export class Server {
1059
1026
  }
1060
1027
  /**
1061
1028
  * onRequest short-circuit path. Synchronous as long as every hook returns synchronously (the
1062
- * common case — e.g. CORS returning `undefined` for a non-preflight request): an `async` version
1029
+ * common case - e.g. CORS returning `undefined` for a non-preflight request): an `async` version
1063
1030
  * here put EVERY request of any app with one onRequest hook onto the promise machinery, profiled
1064
1031
  * at ~13% of a realistic request. The first hook that returns a Promise hands the REMAINING
1065
1032
  * hooks to the async continuation; behavior is identical.
@@ -1085,7 +1052,7 @@ export class Server {
1085
1052
  return this.routeAndRun(current, platform, finalize, wrapResponse, onTimeout, webFast);
1086
1053
  }
1087
1054
  /** Async tail of {@link runWithOnRequest}: applies the first awaited hook's outcome, then runs
1088
- * the remaining hooks (awaiting freely — we're already async here). */
1055
+ * the remaining hooks (awaiting freely - we're already async here). */
1089
1056
  async continueOnRequest(first, nextIndex, originalRequest, sourceAtAwait, platform, finalize, wrapResponse, onTimeout, webFast) {
1090
1057
  let current = sourceAtAwait;
1091
1058
  let early = first;
@@ -1140,7 +1107,7 @@ export class Server {
1140
1107
  /** Run a route that has already been matched by the runtime or Nifra's portable router. */
1141
1108
  runMatched(source, platform, entry, params, search, finalize, wrapResponse, onTimeout, webFast) {
1142
1109
  // An idempotency route runs its dedupe lane first; on a fresh key it delegates to the normal lanes
1143
- // (with the body buffered). All non-idempotent routes skip straight to the lanes — no added cost.
1110
+ // (with the body buffered). All non-idempotent routes skip straight to the lanes - no added cost.
1144
1111
  // The runtime is always present when a route resolved idempotency (enforced at registration).
1145
1112
  if (entry.idempotent !== undefined && this.idempotencyRuntime !== undefined) {
1146
1113
  return this.idempotencyRuntime.run(entry.idempotent, requestOf(source), platform, entry, params, search, wrapResponse, {
@@ -1188,7 +1155,7 @@ export class Server {
1188
1155
  const outcome = webFast && plan.fusedWeb !== undefined
1189
1156
  ? plan.fusedWeb(source, params, search, signal, budget, platform, false)
1190
1157
  : plan.run(this, entry, source, params, search, signal, budget, platform, finalize, wrapResponse);
1191
- // The request timeout only bounds work that is actually pending — a synchronous (bare) result is
1158
+ // The request timeout only bounds work that is actually pending - a synchronous (bare) result is
1192
1159
  // already complete and can't time out, so it's returned as-is (no 503 race, no promise).
1193
1160
  if (controller !== undefined && outcome instanceof Promise) {
1194
1161
  const timedOut = admission?.inherited === true
@@ -1227,11 +1194,11 @@ export class Server {
1227
1194
  }
1228
1195
  /**
1229
1196
  * The synchronous fast path selected by a route's execution plan: apply static decorations, call the
1230
- * handler, render the result — **no `await`** unless the handler itself returns a promise. It mirrors
1197
+ * handler, render the result - **no `await`** unless the handler itself returns a promise. It mirrors
1231
1198
  * the bare slice of {@link runLifecycle} (which a bare route would otherwise no-op through) and shares
1232
1199
  * {@link logRequestError}; a bare route has no `onError` hooks, so error handling is fully synchronous
1233
1200
  * (a thrown `Response` is control flow; anything else is a logged flat 500). This is where nifra skips
1234
- * the per-request async-frame tax — the same win codegen routers get, but without `eval`.
1201
+ * the per-request async-frame tax - the same win codegen routers get, but without `eval`.
1235
1202
  */
1236
1203
  runBare(entry, ctx, finalize, wrapResponse) {
1237
1204
  let result;
@@ -1249,12 +1216,12 @@ export class Server {
1249
1216
  }
1250
1217
  return finalize(result, responseSet(ctx));
1251
1218
  }
1252
- /** Bare-route error rendering — identical to {@link runLifecycle}'s catch minus the (absent) onError
1219
+ /** Bare-route error rendering - identical to {@link runLifecycle}'s catch minus the (absent) onError
1253
1220
  * loop: a thrown `Response` is returned as deliberate control flow; anything else is logged + 500. */
1254
1221
  /**
1255
1222
  * Build a route's fused Web renderer. Composition happens once at
1256
1223
  * registration; the returned closure is what every request to the route runs. Behavior is
1257
- * byte-identical to the generic `runBare`/`runContextlessBare` + `toResponse` pair — same
1224
+ * byte-identical to the generic `runBare`/`runContextlessBare` + `toResponse` pair - same
1258
1225
  * decoration order, same error routing (thrown `Response` = control flow; anything else logs and
1259
1226
  * 500s), same respond semantics (the lifecycle parity suite pins it).
1260
1227
  */
@@ -1266,7 +1233,7 @@ export class Server {
1266
1233
  return jsonError(500, "internal_error");
1267
1234
  };
1268
1235
  if (contextless && decorations === undefined) {
1269
- // `() => ...` can't observe the context — skip allocating one entirely (errors still build
1236
+ // `() => ...` can't observe the context - skip allocating one entirely (errors still build
1270
1237
  // one for the structured log, exactly like runContextlessBare).
1271
1238
  const contextlessHandler = handler;
1272
1239
  return (source, params, search, signal, budget, platform, nativeContext) => {
@@ -1355,10 +1322,10 @@ export class Server {
1355
1322
  return Promise.resolve(wrapResponse(jsonError(415, "unsupported_media_type")));
1356
1323
  }
1357
1324
  // Inline fast path (profiled): a framed, in-cap, non-chunked body parses with the native
1358
- // `req.json()` directly — one promise, one `.then` closure per request. The generic
1325
+ // `req.json()` directly - one promise, one `.then` closure per request. The generic
1359
1326
  // `readBoundedJson` (an extra async-fn frame + per-request `finish`/`applyValidation`
1360
1327
  // closures) is kept for the chunked / length-less / oversized cases it exists for.
1361
- // Semantics are identical to readBoundedJsonSource — same checks, same error codes.
1328
+ // Semantics are identical to readBoundedJsonSource - same checks, same error codes.
1362
1329
  const declared = headerOf(source, "content-length");
1363
1330
  if (declared !== null) {
1364
1331
  const length = parseContentLength(declared);
@@ -1386,7 +1353,7 @@ export class Server {
1386
1353
  return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse);
1387
1354
  }
1388
1355
  }
1389
- /** Validate + run the handler for the bodyOnly path — shared by the inline fast path and the
1356
+ /** Validate + run the handler for the bodyOnly path - shared by the inline fast path and the
1390
1357
  * streaming fallback. A method (not per-request closures) so the hot path allocates nothing
1391
1358
  * beyond the one `.then` continuation. */
1392
1359
  finishBodyOnly(entry, parsed, ctx, finalize, wrapResponse) {
@@ -1484,7 +1451,7 @@ export class Server {
1484
1451
  }
1485
1452
  runQueryOnly(entry, ctx, finalize, wrapResponse) {
1486
1453
  try {
1487
- // Call the validator directly for the raw StandardResult (read `.issues`/`.value`) — skip
1454
+ // Call the validator directly for the raw StandardResult (read `.issues`/`.value`) - skip
1488
1455
  // `validateStandard`'s per-request wrapper-object allocation, mirroring the bodyOnly path.
1489
1456
  const validation = entry.schema.query["~standard"].validate(queryObjectOf(ctx[CONTEXT_SEARCH]));
1490
1457
  if (validation instanceof Promise) {
@@ -1525,7 +1492,7 @@ export class Server {
1525
1492
  }
1526
1493
  /**
1527
1494
  * Thread the response through each global `onResponse` hook. Stays SYNCHRONOUS until a hook
1528
- * actually returns a Promise — an `async` version forced a promise + microtask on EVERY response
1495
+ * actually returns a Promise - an `async` version forced a promise + microtask on EVERY response
1529
1496
  * of any app with an onResponse hook (cors/securityHeaders/etag/timing all use onResponse), the
1530
1497
  * same ~13%/req tax the onRequest walk was de-async'd to avoid. The first async hook hands the
1531
1498
  * rest to {@link continueOnResponse}.
@@ -1604,7 +1571,7 @@ export class Server {
1604
1571
  /**
1605
1572
  * Bound the response time. On timeout we abort `ctx.signal` (so cancellation-aware
1606
1573
  * handlers can bail) and return 503; the in-flight work keeps running but its
1607
- * result is discarded — JS can't forcibly cancel a promise.
1574
+ * result is discarded - JS can't forcibly cancel a promise.
1608
1575
  */
1609
1576
  async withTimeout(work, controller, onTimeout, timeoutMs) {
1610
1577
  let timer;
@@ -1655,7 +1622,7 @@ export class Server {
1655
1622
  return wrapResponse(queryError);
1656
1623
  }
1657
1624
  // Context extensions: static decorations, then per-request derives.
1658
- // Each hook below only awaits when it actually returns a Promise — a sync hook
1625
+ // Each hook below only awaits when it actually returns a Promise - a sync hook
1659
1626
  // skips the microtask tick (a fast-path; ~64 ns/hook).
1660
1627
  if (entry.hasDecorations)
1661
1628
  Object.assign(ctx, entry.decorations); // skip the no-op on bare routes
@@ -1686,6 +1653,35 @@ export class Server {
1686
1653
  result = transformed instanceof Promise ? await transformed : transformed;
1687
1654
  }
1688
1655
  }
1656
+ // The response contract is checked LAST, after afterHandle, so it holds what actually ships
1657
+ // rather than what the handler happened to return. Only reached by a contracted route: the
1658
+ // registration decision above kept those off the fused/native lanes for exactly this step.
1659
+ const contract = entry.responseContract;
1660
+ if (contract !== undefined) {
1661
+ const checked = contract.runtime.check(contract.schema, result);
1662
+ const outcome = checked instanceof Promise ? await checked : checked;
1663
+ if (outcome.kind === "violation") {
1664
+ // The handler broke a contract it declared itself, so this is a server fault, not the
1665
+ // caller's. Fail closed: the undeclared payload is never sent, and the detail goes to the
1666
+ // (redacting) logger rather than to the client.
1667
+ this.logger.error("response contract violation", {
1668
+ method: ctx.req.method,
1669
+ path: pathnameOf(ctx.req.url),
1670
+ // `detail`, not `message`: the logger uses `message` for its own first argument, so a
1671
+ // field of that name is silently overwritten and the diagnosis disappears.
1672
+ detail: outcome.message,
1673
+ });
1674
+ return wrapResponse(jsonError(500, "internal_error"));
1675
+ }
1676
+ if (outcome.kind === "warn") {
1677
+ this.logger.warn("response contract", {
1678
+ method: ctx.req.method,
1679
+ path: pathnameOf(ctx.req.url),
1680
+ detail: outcome.message,
1681
+ });
1682
+ }
1683
+ result = outcome.value;
1684
+ }
1689
1685
  return finalize(result, responseSet(ctx));
1690
1686
  }
1691
1687
  catch (err) {
@@ -1693,7 +1689,7 @@ export class Server {
1693
1689
  }
1694
1690
  }
1695
1691
  async handleLifecycleError(entry, err, ctx, finalize, wrapResponse) {
1696
- // A *thrown* Response is deliberate control flow, not an error — a guard throws a redirect/401,
1692
+ // A *thrown* Response is deliberate control flow, not an error - a guard throws a redirect/401,
1697
1693
  // an action throws an error page. Return it as-is (Remix/SvelteKit semantics); don't run onError
1698
1694
  // or log it as a 500. This is what makes `throw redirect(...)` / `requireSession(...)` work from
1699
1695
  // any handler or loader.
@@ -1711,7 +1707,7 @@ export class Server {
1711
1707
  this.logRequestError(err, ctx);
1712
1708
  return wrapResponse(jsonError(500, "internal_error"));
1713
1709
  }
1714
- /** Log an unhandled request error to the (redacting) logger — shared by {@link runLifecycle} and the
1710
+ /** Log an unhandled request error to the (redacting) logger - shared by {@link runLifecycle} and the
1715
1711
  * bare fast path ({@link bareError}) so both record the same fields. Never throws; never leaks. */
1716
1712
  logRequestError(err, ctx) {
1717
1713
  this.logger.error("unhandled request error", {
@@ -1738,7 +1734,7 @@ export class Server {
1738
1734
  parsed = form;
1739
1735
  }
1740
1736
  else {
1741
- // multipart/form-data (file uploads) stays 415 on the schema path by design — files don't
1737
+ // multipart/form-data (file uploads) stays 415 on the schema path by design - files don't
1742
1738
  // fit a value schema; use a schema-less route + @nifrajs/uploads helpers for those.
1743
1739
  return jsonError(415, "unsupported_media_type");
1744
1740
  }
@@ -1785,12 +1781,12 @@ export class Server {
1785
1781
  /**
1786
1782
  * Read the body as text, capped at `maxBodyBytes`. Rejects (`null`) on a
1787
1783
  * `Content-Length` over the cap *before* buffering, and aborts mid-stream once the
1788
- * running byte count exceeds it — so a lying or absent length can't force us to
1784
+ * running byte count exceeds it - so a lying or absent length can't force us to
1789
1785
  * buffer an oversized payload.
1790
1786
  *
1791
1787
  * Fast path: when a non-chunked request carries a `Content-Length` within the cap,
1792
- * a native `req.json()` is already bounded — under HTTP/1.1 + HTTP/2 framing the
1793
- * runtime delivers at most `Content-Length` bytes — so we skip the manual stream
1788
+ * a native `req.json()` is already bounded - under HTTP/1.1 + HTTP/2 framing the
1789
+ * runtime delivers at most `Content-Length` bytes - so we skip the manual stream
1794
1790
  * loop and a separate text decode. It trusts the wire
1795
1791
  * *framing*, not the header value: nifra only ever receives framed Requests from the
1796
1792
  * runtime's HTTP server, never a hand-built one with a mismatched length. Chunked or
@@ -1847,7 +1843,7 @@ export class Server {
1847
1843
  * capture semantics; static and `:param` routes take the native lane. */
1848
1844
  buildBunNativeRoutes() {
1849
1845
  // A `clientIp` trust declaration must run the resolver in `dispatch`, which the fused native lane
1850
- // bypasses — so an app that declares trust routes through the fetch lane (where `c.clientIp`
1846
+ // bypasses - so an app that declares trust routes through the fetch lane (where `c.clientIp`
1851
1847
  // resolves) instead of Bun's native table. The allocation-free default keeps native fusion.
1852
1848
  if (this.onRequestHooks.length > 0 ||
1853
1849
  this.wsRouteCount > 0 ||
@@ -1883,7 +1879,7 @@ export class Server {
1883
1879
  * Start a `Bun.serve` instance bound to `port` (use `0` for an ephemeral port).
1884
1880
  *
1885
1881
  * `reusePort` sets `SO_REUSEPORT` so **multiple processes can bind the same port** and the kernel
1886
- * load-balances connections across them — the standard way to use every core (Bun is
1882
+ * load-balances connections across them - the standard way to use every core (Bun is
1887
1883
  * single-threaded per process). Spawn one process per core, each calling
1888
1884
  * `app.listen(PORT, { reusePort: true })`; see `examples/cluster.ts`. Every process must opt in,
1889
1885
  * and all of them must be the same app. Linux balances ~evenly; macOS accepts the flag but may
@@ -1903,14 +1899,14 @@ export class Server {
1903
1899
  }
1904
1900
  // Bun's `Server` is the concrete handle; we expose the stable `RunningServer`
1905
1901
  // subset so the public types don't depend on the ambient `Bun` global. The cast
1906
- // bridges them — Bun's `.port` is `number | undefined` (undefined only for unix
1902
+ // bridges them - Bun's `.port` is `number | undefined` (undefined only for unix
1907
1903
  // sockets, never a TCP `listen`) and its `.stop` returns a promise we don't await.
1908
- // Pass only the request — Bun's `fetch` 2nd arg is the Bun `Server`, not our `platform`.
1904
+ // Pass only the request - Bun's `fetch` 2nd arg is the Bun `Server`, not our `platform`.
1909
1905
  // With WS routes, hand Bun a `websocket` config + a fetch that upgrades matching requests (the
1910
1906
  // `server` 2nd arg is how Bun exposes `upgrade`); otherwise the lean request-only fetch. The
1911
- // `websocket` handlers are one shared dispatcher — each connection's `ws.data.handler` is the
1907
+ // `websocket` handlers are one shared dispatcher - each connection's `ws.data.handler` is the
1912
1908
  // matched route's handler, set by `server.upgrade`.
1913
- // With WS routes, the dispatcher comes from the installed `.use(websocket())` runtime — non-null
1909
+ // With WS routes, the dispatcher comes from the installed `.use(websocket())` runtime - non-null
1914
1910
  // because wsRouteCount > 0 means ws() ran, and ws() requires the runtime at registration.
1915
1911
  const wsHandlers = this.wsRouteCount === 0
1916
1912
  ? undefined
@@ -1953,7 +1949,7 @@ export class Server {
1953
1949
  }
1954
1950
  /**
1955
1951
  * Gracefully stop: wait for in-flight requests to finish (up to `drainMs`), then
1956
- * issue a single terminal stop — graceful if everything drained, forced if
1952
+ * issue a single terminal stop - graceful if everything drained, forced if
1957
1953
  * stragglers remain. Safe to call when not listening.
1958
1954
  *
1959
1955
  * The Bun semantics: poll `pendingRequests` (awaiting
@@ -1974,7 +1970,7 @@ export class Server {
1974
1970
  server.stop(server.pendingRequests > 0); // force-close iff stragglers remain past the deadline
1975
1971
  }
1976
1972
  installSignalHandlers() {
1977
- // Drain, then let the process exit naturally — the stopped server no longer
1973
+ // Drain, then let the process exit naturally - the stopped server no longer
1978
1974
  // holds the event loop open. Opt-in (`gracefulSignals`), so taking over the
1979
1975
  // signals is consented; we don't force `process.exit`.
1980
1976
  const onSignal = () => {
@@ -1985,66 +1981,14 @@ export class Server {
1985
1981
  }
1986
1982
  }
1987
1983
  /**
1988
- * Create a new {@link Server}. Pass an `Env` to type the platform bindings — `server<Env>()` makes
1984
+ * Create a new {@link Server}. Pass an `Env` to type the platform bindings - `server<Env>()` makes
1989
1985
  * `c.env: Env` in every handler + middleware, and types the `env` argument of `app.fetch` /
1990
1986
  * `toFetchHandler`. Omit it and `c.env` is `unknown` (validate/cast before use).
1991
1987
  */
1992
1988
  export function server(options) {
1993
1989
  // `Env` is a phantom type-level marker: the runtime `env` arrives via `app.fetch(req, { env })` at
1994
- // request time, not stored on the builder — so seed the context type with a cast (as `derive`/
1990
+ // request time, not stored on the builder - so seed the context type with a cast (as `derive`/
1995
1991
  // `decorate` do for their `Ctx` extensions).
1996
1992
  return new Server(options);
1997
1993
  }
1998
- /** The single hub Durable Object id nifra routes WS upgrades to (one hub per app — see `@nifrajs/workers`). */
1999
- const NIFRA_WS_HUB_ID = "nifra-ws-hub";
2000
- export function toFetchHandler(app, options) {
2001
- const scheduled = options?.scheduled;
2002
- const webSocketHub = options?.webSocketHub;
2003
- const resolveWs = app.resolveWebSocketUpgrade?.bind(app);
2004
- return {
2005
- fetch: (request, env, ctx) => {
2006
- const platform = { env, waitUntil: (promise) => ctx.waitUntil(promise) };
2007
- // WebSocket broadcast on Workers: route upgrades to the hub Durable Object (it holds the
2008
- // connections + runs the app's pub/sub, so `app.publish` reaches every client). The hub itself
2009
- // resolves the route and rejects non-WS paths. See `@nifrajs/workers`' `createWebSocketHub`.
2010
- if (webSocketHub !== undefined &&
2011
- request.headers.get("upgrade")?.toLowerCase() === "websocket") {
2012
- const ns = webSocketHub(env);
2013
- return ns.get(ns.idFromName(NIFRA_WS_HUB_ID)).fetch(request);
2014
- }
2015
- // Workers WebSockets: a WS upgrade for a registered route becomes a `WebSocketPair` + a 101.
2016
- // Feature-detected, so non-Workers edge runtimes (which lack `WebSocketPair` — e.g. Deno Deploy
2017
- // uses `@nifrajs/deno`'s `Deno.upgradeWebSocket`) simply fall through to the normal `fetch`.
2018
- const Pair = globalThis.WebSocketPair;
2019
- if (resolveWs !== undefined && Pair !== undefined) {
2020
- const accept = (outcome) => {
2021
- if (outcome.kind === "pass")
2022
- return app.fetch(request, platform);
2023
- if (outcome.kind === "reject")
2024
- return outcome.response;
2025
- const pair = new Pair();
2026
- const server = pair[1];
2027
- server.accept();
2028
- // The outcome carries the installed runtime's `attach`, so `toFetchHandler` (a standalone
2029
- // function, no access to the app's private runtime) wires the socket without a static WS import.
2030
- outcome.attach(server, outcome.handler, outcome.data, {
2031
- openNow: true,
2032
- pubsub: outcome.pubsub,
2033
- });
2034
- // `webSocket` is a Workers-only `ResponseInit` field (absent from the standard type), and a
2035
- // 101 status is only valid on the Workers runtime — both gated by the `Pair` feature check.
2036
- return new Response(null, { status: 101, webSocket: pair[0] });
2037
- };
2038
- const out = resolveWs(request, platform);
2039
- return out instanceof Promise ? out.then(accept) : accept(out);
2040
- }
2041
- return app.fetch(request, platform);
2042
- },
2043
- ...(scheduled !== undefined
2044
- ? {
2045
- scheduled: (controller, env, ctx) => scheduled(controller, { env, waitUntil: (promise) => ctx.waitUntil(promise) }),
2046
- }
2047
- : {}),
2048
- };
2049
- }
2050
1994
  //# sourceMappingURL=server.js.map