@nifrajs/core 1.13.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (233) hide show
  1. package/README.md +37 -1
  2. package/dist/assurance.d.ts +11 -2
  3. package/dist/assurance.d.ts.map +1 -1
  4. package/dist/assurance.js +20 -0
  5. package/dist/assurance.js.map +1 -1
  6. package/dist/capabilities.d.ts +54 -2
  7. package/dist/capabilities.d.ts.map +1 -1
  8. package/dist/capabilities.js +240 -5
  9. package/dist/capabilities.js.map +1 -1
  10. package/dist/durable-adapters.d.ts +241 -0
  11. package/dist/durable-adapters.d.ts.map +1 -0
  12. package/dist/durable-adapters.js +697 -0
  13. package/dist/durable-adapters.js.map +1 -0
  14. package/dist/durable-execution.d.ts +349 -0
  15. package/dist/durable-execution.d.ts.map +1 -0
  16. package/dist/durable-execution.js +1173 -0
  17. package/dist/durable-execution.js.map +1 -0
  18. package/dist/effect-ledger.d.ts +9 -0
  19. package/dist/effect-ledger.d.ts.map +1 -0
  20. package/dist/effect-ledger.js +8 -0
  21. package/dist/effect-ledger.js.map +1 -0
  22. package/dist/effect-lifecycle.d.ts +42 -0
  23. package/dist/effect-lifecycle.d.ts.map +1 -0
  24. package/dist/effect-lifecycle.js +66 -0
  25. package/dist/effect-lifecycle.js.map +1 -0
  26. package/dist/effect-scope.d.ts +75 -0
  27. package/dist/effect-scope.d.ts.map +1 -0
  28. package/dist/effect-scope.js +177 -0
  29. package/dist/effect-scope.js.map +1 -0
  30. package/dist/idempotency-plugin.d.ts +13 -0
  31. package/dist/idempotency-plugin.d.ts.map +1 -0
  32. package/dist/idempotency-plugin.js +16 -0
  33. package/dist/idempotency-plugin.js.map +1 -0
  34. package/dist/index.d.ts +6 -35
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +6 -31
  37. package/dist/index.js.map +1 -1
  38. package/dist/internal/capability-runtime.d.ts +62 -1
  39. package/dist/internal/capability-runtime.d.ts.map +1 -1
  40. package/dist/internal/capability-runtime.js +11 -2
  41. package/dist/internal/capability-runtime.js.map +1 -1
  42. package/dist/internal/effect-execution.d.ts +16 -0
  43. package/dist/internal/effect-execution.d.ts.map +1 -0
  44. package/dist/internal/effect-execution.js +50 -0
  45. package/dist/internal/effect-execution.js.map +1 -0
  46. package/dist/internal/safe-equal.d.ts +20 -0
  47. package/dist/internal/safe-equal.d.ts.map +1 -0
  48. package/dist/internal/safe-equal.js +31 -0
  49. package/dist/internal/safe-equal.js.map +1 -0
  50. package/dist/ledger.d.ts +14 -6
  51. package/dist/ledger.d.ts.map +1 -1
  52. package/dist/ledger.js +31 -5
  53. package/dist/ledger.js.map +1 -1
  54. package/dist/mcp.d.ts +7 -0
  55. package/dist/mcp.d.ts.map +1 -0
  56. package/dist/mcp.js +56 -0
  57. package/dist/mcp.js.map +1 -0
  58. package/dist/node-direct.d.ts +5 -0
  59. package/dist/node-direct.d.ts.map +1 -0
  60. package/dist/node-direct.js +27 -0
  61. package/dist/node-direct.js.map +1 -0
  62. package/dist/reconciliation-worker.d.ts +108 -0
  63. package/dist/reconciliation-worker.d.ts.map +1 -0
  64. package/dist/reconciliation-worker.js +239 -0
  65. package/dist/reconciliation-worker.js.map +1 -0
  66. package/dist/reflection.d.ts +5 -0
  67. package/dist/reflection.d.ts.map +1 -1
  68. package/dist/reflection.js +2 -0
  69. package/dist/reflection.js.map +1 -1
  70. package/dist/server/client-ip.d.ts +35 -0
  71. package/dist/server/client-ip.d.ts.map +1 -0
  72. package/dist/server/client-ip.js +42 -0
  73. package/dist/server/client-ip.js.map +1 -0
  74. package/dist/server/context.d.ts +47 -2
  75. package/dist/server/context.d.ts.map +1 -1
  76. package/dist/server/contract.d.ts +14 -4
  77. package/dist/server/contract.d.ts.map +1 -1
  78. package/dist/server/contract.js +8 -2
  79. package/dist/server/contract.js.map +1 -1
  80. package/dist/server/http.d.ts +14 -0
  81. package/dist/server/http.d.ts.map +1 -0
  82. package/dist/server/http.js +45 -0
  83. package/dist/server/http.js.map +1 -0
  84. package/dist/server/idempotency-lane.d.ts +45 -0
  85. package/dist/server/idempotency-lane.d.ts.map +1 -0
  86. package/dist/server/idempotency-lane.js +205 -0
  87. package/dist/server/idempotency-lane.js.map +1 -0
  88. package/dist/server/install.d.ts +21 -0
  89. package/dist/server/install.d.ts.map +1 -0
  90. package/dist/server/install.js +21 -0
  91. package/dist/server/install.js.map +1 -0
  92. package/dist/server/ledger-lane.d.ts +53 -0
  93. package/dist/server/ledger-lane.d.ts.map +1 -0
  94. package/dist/server/ledger-lane.js +82 -0
  95. package/dist/server/ledger-lane.js.map +1 -0
  96. package/dist/server/mcp-hook.d.ts +28 -0
  97. package/dist/server/mcp-hook.d.ts.map +1 -0
  98. package/dist/server/mcp-hook.js +2 -0
  99. package/dist/server/mcp-hook.js.map +1 -0
  100. package/dist/server/node-outcome-hook.d.ts +9 -0
  101. package/dist/server/node-outcome-hook.d.ts.map +1 -0
  102. package/dist/server/node-outcome-hook.js +2 -0
  103. package/dist/server/node-outcome-hook.js.map +1 -0
  104. package/dist/server/node-outcome.d.ts +37 -0
  105. package/dist/server/node-outcome.d.ts.map +1 -0
  106. package/dist/server/node-outcome.js +75 -0
  107. package/dist/server/node-outcome.js.map +1 -0
  108. package/dist/server/plugin.d.ts +78 -0
  109. package/dist/server/plugin.d.ts.map +1 -0
  110. package/dist/server/plugin.js +57 -0
  111. package/dist/server/plugin.js.map +1 -0
  112. package/dist/server/query.d.ts +18 -0
  113. package/dist/server/query.d.ts.map +1 -0
  114. package/dist/server/query.js +102 -0
  115. package/dist/server/query.js.map +1 -0
  116. package/dist/server/registry.d.ts +31 -6
  117. package/dist/server/registry.d.ts.map +1 -1
  118. package/dist/server/request-context.d.ts +51 -0
  119. package/dist/server/request-context.d.ts.map +1 -0
  120. package/dist/server/request-context.js +184 -0
  121. package/dist/server/request-context.js.map +1 -0
  122. package/dist/server/respond.d.ts +17 -0
  123. package/dist/server/respond.d.ts.map +1 -0
  124. package/dist/server/respond.js +81 -0
  125. package/dist/server/respond.js.map +1 -0
  126. package/dist/server/runtime-core.d.ts +36 -0
  127. package/dist/server/runtime-core.d.ts.map +1 -0
  128. package/dist/server/runtime-core.js +51 -0
  129. package/dist/server/runtime-core.js.map +1 -0
  130. package/dist/server/server-types.d.ts +203 -0
  131. package/dist/server/server-types.d.ts.map +1 -0
  132. package/dist/server/server-types.js +2 -0
  133. package/dist/server/server-types.js.map +1 -0
  134. package/dist/server/server.d.ts +119 -345
  135. package/dist/server/server.d.ts.map +1 -1
  136. package/dist/server/server.js +247 -833
  137. package/dist/server/server.js.map +1 -1
  138. package/dist/server/sse-hook.d.ts +10 -0
  139. package/dist/server/sse-hook.d.ts.map +1 -0
  140. package/dist/server/sse-hook.js +2 -0
  141. package/dist/server/sse-hook.js.map +1 -0
  142. package/dist/server/sse.d.ts +7 -23
  143. package/dist/server/sse.d.ts.map +1 -1
  144. package/dist/server/sse.js +18 -0
  145. package/dist/server/sse.js.map +1 -1
  146. package/dist/server/transport-lane.d.ts +28 -0
  147. package/dist/server/transport-lane.d.ts.map +1 -0
  148. package/dist/server/transport-lane.js +106 -0
  149. package/dist/server/transport-lane.js.map +1 -0
  150. package/dist/server/websocket.d.ts +23 -1
  151. package/dist/server/websocket.d.ts.map +1 -1
  152. package/dist/server/websocket.js +10 -1
  153. package/dist/server/websocket.js.map +1 -1
  154. package/dist/server/ws-hook.d.ts +8 -11
  155. package/dist/server/ws-hook.d.ts.map +1 -1
  156. package/dist/server/ws-hook.js +7 -14
  157. package/dist/server/ws-hook.js.map +1 -1
  158. package/dist/server.d.ts +21 -0
  159. package/dist/server.d.ts.map +1 -0
  160. package/dist/server.js +16 -0
  161. package/dist/server.js.map +1 -0
  162. package/dist/transport-codec-rich.d.ts +8 -0
  163. package/dist/transport-codec-rich.d.ts.map +1 -0
  164. package/dist/transport-codec-rich.js +14 -0
  165. package/dist/transport-codec-rich.js.map +1 -0
  166. package/dist/transport-codec.d.ts +31 -0
  167. package/dist/transport-codec.d.ts.map +1 -0
  168. package/dist/transport-codec.js +190 -0
  169. package/dist/transport-codec.js.map +1 -0
  170. package/dist/transport-plugin.d.ts +3 -0
  171. package/dist/transport-plugin.d.ts.map +1 -0
  172. package/dist/transport-plugin.js +3 -0
  173. package/dist/transport-plugin.js.map +1 -0
  174. package/dist/wire.d.ts +58 -0
  175. package/dist/wire.d.ts.map +1 -0
  176. package/dist/wire.js +445 -0
  177. package/dist/wire.js.map +1 -0
  178. package/dist/ws.d.ts +16 -7
  179. package/dist/ws.d.ts.map +1 -1
  180. package/dist/ws.js +25 -10
  181. package/dist/ws.js.map +1 -1
  182. package/package.json +122 -6
  183. package/src/assurance.ts +36 -1
  184. package/src/capabilities.ts +306 -5
  185. package/src/durable-adapters.ts +1039 -0
  186. package/src/durable-execution.ts +1676 -0
  187. package/src/effect-ledger.ts +9 -0
  188. package/src/effect-lifecycle.ts +120 -0
  189. package/src/effect-scope.ts +267 -0
  190. package/src/idempotency-plugin.ts +17 -0
  191. package/src/index.ts +6 -133
  192. package/src/internal/capability-runtime.ts +87 -1
  193. package/src/internal/effect-execution.ts +70 -0
  194. package/src/internal/safe-equal.ts +27 -0
  195. package/src/ledger.ts +45 -11
  196. package/src/mcp.ts +65 -0
  197. package/src/node-direct.ts +37 -0
  198. package/src/reconciliation-worker.ts +372 -0
  199. package/src/reflection.ts +7 -0
  200. package/src/server/client-ip.ts +60 -0
  201. package/src/server/context.ts +50 -2
  202. package/src/server/contract.ts +26 -7
  203. package/src/server/http.ts +58 -0
  204. package/src/server/idempotency-lane.ts +321 -0
  205. package/src/server/install.ts +28 -0
  206. package/src/server/ledger-lane.ts +125 -0
  207. package/src/server/mcp-hook.ts +41 -0
  208. package/src/server/node-outcome-hook.ts +9 -0
  209. package/src/server/node-outcome.ts +112 -0
  210. package/src/server/plugin.ts +92 -0
  211. package/src/server/query.ts +119 -0
  212. package/src/server/registry.ts +36 -6
  213. package/src/server/request-context.ts +271 -0
  214. package/src/server/respond.ts +94 -0
  215. package/src/server/runtime-core.ts +74 -0
  216. package/src/server/server-types.ts +204 -0
  217. package/src/server/server.ts +448 -1314
  218. package/src/server/sse-hook.ts +14 -0
  219. package/src/server/sse.ts +26 -0
  220. package/src/server/transport-lane.ts +141 -0
  221. package/src/server/websocket.ts +35 -1
  222. package/src/server/ws-hook.ts +8 -19
  223. package/src/server.ts +92 -0
  224. package/src/transport-codec-rich.ts +20 -0
  225. package/src/transport-codec.ts +250 -0
  226. package/src/transport-plugin.ts +2 -0
  227. package/src/wire.ts +474 -0
  228. package/src/ws.ts +33 -10
  229. package/dist/invariants.d.ts +0 -70
  230. package/dist/invariants.d.ts.map +0 -1
  231. package/dist/invariants.js +0 -487
  232. package/dist/invariants.js.map +0 -1
  233. package/src/invariants.ts +0 -604
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Symbol-keyed install seams the opt-in feature plugins (`idempotency()`, `effectLedger()`) use to
3
+ * hand their runtime to a server. Kept in a dependency-free leaf, and keyed by symbol, so:
4
+ * - the kernel can implement the seam without importing a feature's code (importing a bare symbol
5
+ * never drags the lane's implementation into the base bundle), and
6
+ * - the seam stays off the server's public typed surface (symbol-keyed methods are not part of it).
7
+ * Both sides reference the SAME registered symbol, so no module has to import the other.
8
+ */
9
+
10
+ /** @internal Install the idempotency runtime on a server (called by the `idempotency()` plugin). */
11
+ export const INSTALL_IDEMPOTENCY: unique symbol = Symbol.for("@nifrajs/core/install-idempotency")
12
+
13
+ /** @internal Install the effect-ledger runtime on a server (called by the `effectLedger()` plugin). */
14
+ export const INSTALL_EFFECT_LEDGER: unique symbol = Symbol.for(
15
+ "@nifrajs/core/install-effect-ledger",
16
+ )
17
+
18
+ /** @internal Install the MCP runtime on a server (called by the `mcp()` plugin). */
19
+ export const INSTALL_MCP: unique symbol = Symbol.for("@nifrajs/core/install-mcp")
20
+
21
+ /** @internal Install the SSE streaming runtime on a server (called by the `sse()` plugin). */
22
+ export const INSTALL_SSE: unique symbol = Symbol.for("@nifrajs/core/install-sse")
23
+
24
+ /** @internal Install the Node-direct renderer on a server (called by the `nodeDirect()` plugin). */
25
+ export const INSTALL_NODE_DIRECT: unique symbol = Symbol.for("@nifrajs/core/install-node-direct")
26
+
27
+ /** @internal Install the WebSocket runtime on a server (called by the `websocket()` plugin). */
28
+ export const INSTALL_WS: unique symbol = Symbol.for("@nifrajs/core/install-ws")
@@ -0,0 +1,125 @@
1
+ /**
2
+ * The opt-in per-request effect ledger. Installed by `.use(effectLedger())` and reached only by routes
3
+ * that declare `schema.capabilities`, so the kernel never statically imports this code: a bare
4
+ * `server()` tree-shakes the ledger machinery out.
5
+ *
6
+ * The kernel resolves a capability-declaring route's ledger wiring at registration (via
7
+ * {@link EffectLedgerRuntime.resolve}) and, on the request path, creates + attaches a ledger before the
8
+ * handler and settles it after (via {@link EffectLedgerRuntime.create}/`attach`/`settle`). Nothing here
9
+ * depends on server internals; the sink-error log is supplied per settle.
10
+ */
11
+ import {
12
+ attachEffectLedger,
13
+ createRequestLedger,
14
+ DEFAULT_MAX_ENTRIES,
15
+ type EffectLedgerOptions,
16
+ type RequestLedger,
17
+ } from "../ledger.ts"
18
+ import { INSTALL_EFFECT_LEDGER } from "./install.ts"
19
+ import type { AnyServer, IdentityPlugin } from "./server.ts"
20
+
21
+ /** Registration-resolved effect-ledger wiring for one capability-declaring route. */
22
+ export interface ResolvedEffectLedger {
23
+ readonly sink: EffectLedgerOptions["sink"]
24
+ readonly maxEntries: number
25
+ readonly chain: boolean
26
+ /** The registered route pattern - what the sealed ledger names (never the concrete URL). */
27
+ readonly method: string
28
+ readonly path: string
29
+ /** The route's declared capability tokens, surfaced on the sealed ledger. */
30
+ readonly declared: readonly string[]
31
+ }
32
+
33
+ /** The injected effect-ledger implementation the kernel calls through when the plugin is installed. */
34
+ export interface EffectLedgerRuntime {
35
+ /** Resolve a capability-declaring route into its ledger wiring, or `undefined` when it declares none. */
36
+ resolve(
37
+ capabilities: readonly string[],
38
+ method: string,
39
+ path: string,
40
+ ): ResolvedEffectLedger | undefined
41
+ /** Create the per-request ledger for a ledgered route. */
42
+ create(resolved: ResolvedEffectLedger): RequestLedger
43
+ /** Attach a ledger to the request context so `useCapability` can append to it. */
44
+ attach(context: object, ledger: RequestLedger): void
45
+ /** Seal the ledger and deliver it to the sink (empty ledgers are not delivered). */
46
+ settle<T>(
47
+ ledger: RequestLedger,
48
+ resolved: ResolvedEffectLedger,
49
+ value: T,
50
+ onSinkError: (fields: { method: string; path: string; name: string }) => void,
51
+ ): Promise<T>
52
+ }
53
+
54
+ /**
55
+ * Build an effect-ledger runtime from the app-wide options. `sink` receives the sealed, token-only
56
+ * ledger when a capability-declaring route settles with recorded entries; `maxEntries` bounds a single
57
+ * request's ledger; `chain` opts into the per-entry hash chain.
58
+ */
59
+ export function createEffectLedgerRuntime(options: EffectLedgerOptions): EffectLedgerRuntime {
60
+ if (typeof options.sink !== "function") {
61
+ throw new TypeError("effectLedger.sink must be a function")
62
+ }
63
+ const maxEntries = options.maxEntries ?? DEFAULT_MAX_ENTRIES
64
+ if (!Number.isSafeInteger(maxEntries) || maxEntries < 1) {
65
+ throw new RangeError("effectLedger.maxEntries must be a positive safe integer")
66
+ }
67
+ const chain = options.chain ?? false
68
+ const sink = options.sink
69
+
70
+ return {
71
+ resolve(capabilities, method, path) {
72
+ if (capabilities.length === 0) return undefined
73
+ return Object.freeze({ sink, maxEntries, chain, method, path, declared: capabilities })
74
+ },
75
+ create(resolved) {
76
+ return createRequestLedger({
77
+ method: resolved.method,
78
+ path: resolved.path,
79
+ declared: resolved.declared,
80
+ maxEntries: resolved.maxEntries,
81
+ chain: resolved.chain,
82
+ })
83
+ },
84
+ attach(context, ledger) {
85
+ attachEffectLedger(context, ledger)
86
+ },
87
+ async settle(ledger, resolved, value, onSinkError) {
88
+ const sealed = await ledger.seal()
89
+ if (sealed.entries.length === 0) return value
90
+ try {
91
+ await resolved.sink(sealed)
92
+ } catch (err) {
93
+ // Token-only: the route pattern and the failure name - never entry payloads (there are none)
94
+ // and never the request. The ledger is observational, so a sink outage never fails the effect.
95
+ onSinkError({
96
+ method: resolved.method,
97
+ path: resolved.path,
98
+ name: err instanceof Error ? err.name : "Error",
99
+ })
100
+ }
101
+ return value
102
+ },
103
+ }
104
+ }
105
+
106
+ /** The install seam a server exposes so the `effectLedger()` plugin can hand it a runtime. */
107
+ interface EffectLedgerInstallable {
108
+ [INSTALL_EFFECT_LEDGER](runtime: EffectLedgerRuntime): void
109
+ }
110
+
111
+ /**
112
+ * Enable the per-request effect ledger. Each route that declares `schema.capabilities` gets a bounded,
113
+ * token-only ledger; `useCapability(c, id, …)` appends one entry per effect, and the sink receives the
114
+ * sealed ledger when the response settles (only when it recorded entries). Token-only by construction -
115
+ * capability ids, phases, counters, digests; never payloads, values, or the concrete URL (the route
116
+ * pattern is recorded). Without this plugin, capability-declaring routes simply carry no ledger.
117
+ */
118
+ export function effectLedger(options: EffectLedgerOptions): IdentityPlugin {
119
+ const runtime = createEffectLedgerRuntime(options)
120
+ const apply = <S extends AnyServer>(app: S): S => {
121
+ ;(app as unknown as EffectLedgerInstallable)[INSTALL_EFFECT_LEDGER](runtime)
122
+ return app
123
+ }
124
+ return Object.assign(apply, { pluginName: "nifra:effect-ledger" }) as IdentityPlugin
125
+ }
@@ -0,0 +1,41 @@
1
+ import type { StandardSchemaV1 } from "../schema/standard.ts"
2
+ import type { Context, RouteSchema } from "./context.ts"
3
+ import type {
4
+ McpPromptDescriptor,
5
+ McpResourceDescriptor,
6
+ PromptArgument,
7
+ RouteDescriptor,
8
+ ToolAnnotations,
9
+ } from "./server-types.ts"
10
+
11
+ export interface McpToolConfig {
12
+ readonly description: string
13
+ readonly input: StandardSchemaV1
14
+ readonly output?: StandardSchemaV1
15
+ readonly annotations?: ToolAnnotations
16
+ }
17
+
18
+ export interface McpToolPlan {
19
+ readonly path: string
20
+ readonly schema: RouteSchema
21
+ readonly run: (context: Context) => unknown
22
+ readonly descriptor: NonNullable<RouteDescriptor["tool"]>
23
+ }
24
+
25
+ export interface McpRuntime {
26
+ tool(
27
+ name: string,
28
+ config: McpToolConfig,
29
+ handler: (input: unknown, context: Context) => unknown,
30
+ ): McpToolPlan
31
+ resource(
32
+ uri: string,
33
+ config: { readonly name: string; readonly description?: string; readonly mimeType?: string },
34
+ read: McpResourceDescriptor["read"],
35
+ ): McpResourceDescriptor
36
+ prompt(
37
+ name: string,
38
+ config: { readonly description: string; readonly arguments?: readonly PromptArgument[] },
39
+ handler: McpPromptDescriptor["handler"],
40
+ ): McpPromptDescriptor
41
+ }
@@ -0,0 +1,9 @@
1
+ /** Lazy Node-direct renderer seam. The base server retains the typed method but not its renderer. */
2
+ import type { NodeServeOutcome } from "./node-outcome.ts"
3
+ import type { CtxSet } from "./server.ts"
4
+
5
+ export interface NodeOutcomeRuntime {
6
+ toOutcome(result: unknown, set: CtxSet): NodeServeOutcome
7
+ fromResponse(response: Response): NodeServeOutcome
8
+ timeout(): NodeServeOutcome
9
+ }
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Node-direct outcome shaping: the `@nifrajs/node` bridge's render form and the `Response` -> outcome
3
+ * conversion. Mirrors `respond.ts` but skips the undici `Response` build where the adapter can write a
4
+ * plain-data render straight to the socket. Imports only runtime-core + respond + the spine types.
5
+ */
6
+ import { appendCookiesToResponse } from "./respond.ts"
7
+ import { type HandlerResult, isResponseResult } from "./runtime-core.ts"
8
+ import type { CtxSet } from "./server.ts"
9
+
10
+ /**
11
+ * What {@link Server.resolveNode} returns: either a plain-data render the `@nifrajs/node` adapter writes
12
+ * to the socket directly (`kind: "json"` - status + headers + cookies + a pre-stringified body, **no**
13
+ * undici `Response` built or drained), a marked buffered response body (`kind: "body"` - e.g.
14
+ * @nifrajs/web's non-deferred SSR HTML), or a `Response` (`kind: "response"`) for everything else
15
+ * (redirects, 404/405/errors, unmarked or streaming bodies). Internal to the nifra<->node bridge.
16
+ */
17
+ export type NodeServeOutcome =
18
+ | { readonly kind: "response"; readonly response: Response }
19
+ | {
20
+ readonly kind: "json"
21
+ readonly status: number
22
+ /** `c.set.headers` backing, or `undefined` when the handler never set a header. */
23
+ readonly headers: Readonly<Record<string, string>> | undefined
24
+ /** Queued `Set-Cookie` lines, or `undefined`; the adapter emits one header line each. */
25
+ readonly cookies: readonly string[] | undefined
26
+ /** The JSON body already stringified, or `null` for an empty (204) response. */
27
+ readonly body: string | null
28
+ }
29
+ | {
30
+ readonly kind: "body"
31
+ readonly status: number
32
+ readonly headers: Readonly<Record<string, string | readonly string[]>> | undefined
33
+ readonly body: string | Uint8Array
34
+ }
35
+
36
+ /**
37
+ * `finalize` for the node-direct path - mirror of `toResponse` that skips the `Response` build:
38
+ * a plain value becomes pre-stringified JSON primitives (the adapter `JSON.stringify`s once, here, not
39
+ * via `Response.json` + a body drain); a handler-returned `Response` is wrapped as-is, with queued
40
+ * cookies appended exactly as `toResponse` does (so the set-cookie-then-`redirect()` pattern still
41
+ * works on Node).
42
+ */
43
+ export function toNodeOutcome(result: HandlerResult, set: CtxSet): NodeServeOutcome {
44
+ if (isResponseResult(result)) {
45
+ const body = result.toNodeBody?.()
46
+ if (body !== undefined) {
47
+ return {
48
+ kind: "body",
49
+ status: body.status,
50
+ headers: appendCookiesToNodeHeaders(body.headers, set._cookies),
51
+ body: body.body,
52
+ }
53
+ }
54
+ return nodeOutcomeFromResponse(appendCookiesToResponse(result.toResponse(), set))
55
+ }
56
+ if (result instanceof Response) {
57
+ return nodeOutcomeFromResponse(appendCookiesToResponse(result, set))
58
+ }
59
+ const status = set.status ?? (result === undefined ? 204 : 200)
60
+ return {
61
+ kind: "json",
62
+ status,
63
+ headers: set._headers,
64
+ cookies: set._cookies,
65
+ body: result === undefined ? null : JSON.stringify(result),
66
+ }
67
+ }
68
+
69
+ const NODE_RESPONSE_BODY = Symbol.for("nifra.response.body")
70
+
71
+ export function nodeOutcomeFromResponse(response: Response): NodeServeOutcome {
72
+ const body = nodeResponseBody(response)
73
+ return body === undefined
74
+ ? { kind: "response", response }
75
+ : { kind: "body", status: response.status, headers: responseHeadersForNode(response), body }
76
+ }
77
+
78
+ function nodeResponseBody(response: Response): string | Uint8Array | undefined {
79
+ if (response.bodyUsed) return undefined
80
+ const body = (response as { readonly [NODE_RESPONSE_BODY]?: unknown })[NODE_RESPONSE_BODY]
81
+ return typeof body === "string" || body instanceof Uint8Array ? body : undefined
82
+ }
83
+
84
+ function responseHeadersForNode(
85
+ response: Response,
86
+ ): Readonly<Record<string, string | readonly string[]>> | undefined {
87
+ let headers: Record<string, string | readonly string[]> | undefined
88
+ response.headers.forEach((value, key) => {
89
+ headers ??= {}
90
+ headers[key] = value
91
+ })
92
+ const setCookies = response.headers.getSetCookie?.()
93
+ if (setCookies !== undefined && setCookies.length > 0) {
94
+ headers ??= {}
95
+ headers["set-cookie"] = setCookies
96
+ }
97
+ return headers
98
+ }
99
+
100
+ function appendCookiesToNodeHeaders(
101
+ headers: Readonly<Record<string, string | readonly string[]>> | undefined,
102
+ cookies: readonly string[] | undefined,
103
+ ): Readonly<Record<string, string | readonly string[]>> | undefined {
104
+ if (cookies === undefined || cookies.length === 0) return headers
105
+ const out: Record<string, string | readonly string[]> =
106
+ headers === undefined ? {} : { ...headers }
107
+ const existing = out["set-cookie"]
108
+ const setCookies =
109
+ existing === undefined ? [] : typeof existing === "string" ? [existing] : [...existing]
110
+ out["set-cookie"] = [...setCookies, ...cookies]
111
+ return out
112
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Plugin definers and their types. A plugin is a function that augments an app (calling
3
+ * `use`/`derive`/`decorate` and/or registering routes) and returns it; these helpers attach a name
4
+ * for idempotent dedupe and pin the type-threading so `.use()` preserves the caller's typed server.
5
+ */
6
+ import type { AnyServer } from "./server.ts"
7
+
8
+ /**
9
+ * A nifra **plugin**: a function that augments an app - calling `use`/`derive`/`decorate` and/or
10
+ * registering routes - and returns it. Because `derive`/`decorate` are type-threaded, an **inline**
11
+ * `app.use((a) => a.derive(...).decorate(...))` carries the added context to handlers defined after
12
+ * it (the `use` overload is generic over the concrete `this`). Wrap with {@link definePlugin} to
13
+ * attach a name for idempotent dedupe (applying the same named plugin twice - e.g. transitively - is
14
+ * a no-op).
15
+ */
16
+ export type NifraPlugin<In extends AnyServer = AnyServer, Out extends AnyServer = In> = ((
17
+ app: In,
18
+ ) => Out) & { readonly pluginName?: string }
19
+
20
+ /**
21
+ * A named type-identity plugin built with {@link defineIdentityPlugin}. It returns the same concrete
22
+ * server type it receives, preserving the caller's typed registry and context across `.use()` while
23
+ * still allowing the plugin to register runtime hooks or handlers.
24
+ */
25
+ export type IdentityPlugin = (<S extends AnyServer>(app: S) => S) & {
26
+ readonly pluginName?: string
27
+ }
28
+
29
+ /**
30
+ * Name + ergonomics for a plugin that **adds typed context** (`derive`/`decorate`). `app.use(myPlugin)`
31
+ * applies it once; a second `use` of the same name is skipped (idempotent), so plugins can depend on each
32
+ * other without double-registering hooks.
33
+ *
34
+ * ```ts
35
+ * export const requestId = definePlugin("requestId", (app) => app.derive(() => ({ requestId: uuid() })))
36
+ * app.use(requestId) // downstream handlers see c.requestId
37
+ * ```
38
+ *
39
+ * FOOTGUN: only use this for a plugin that adds context. For a plugin that **mounts routes/hooks but adds
40
+ * NO context** (an auth router, an audit logger), use {@link defineRouterPlugin} ({@link defineIdentityPlugin}).
41
+ * `definePlugin((app) => app.get(...))` infers `app: Server<any, any>`, so `.use()` returns `Server<any, any>`
42
+ * and your whole typed client silently collapses to `any` - no type error, no runtime error.
43
+ */
44
+ export function definePlugin<In extends AnyServer, Out extends AnyServer>(
45
+ name: string,
46
+ apply: (app: In) => Out,
47
+ ): NifraPlugin<In, Out> {
48
+ return Object.assign(apply, { pluginName: name }) as NifraPlugin<In, Out>
49
+ }
50
+
51
+ /**
52
+ * Define a type-**identity** plugin: it registers routes/hooks as a side effect but returns the app with
53
+ * its `Registry` + `Context` UNCHANGED. Use this (not {@link definePlugin}) for any plugin that doesn't
54
+ * add context types - e.g. one mounting an auth handler. It threads the caller's *concrete* server type
55
+ * through `use`, so routes declared after `app.use(plugin)` keep their types.
56
+ *
57
+ * Why a dedicated helper: `definePlugin((app) => app)` infers `app: Server<any, any>`, so `use` returns
58
+ * `Server<any, any>` and the whole typed client collapses to `any`. The explicit generic return type here
59
+ * (which a plain `Object.assign` can't preserve) is what keeps `use` returning the precise server type.
60
+ *
61
+ * ```ts
62
+ * export const audit = defineIdentityPlugin("audit", (app) => app.onResponse(logResponse))
63
+ * const api = server().get("/a", h).use(audit).get("/b", h) // /a AND /b stay typed
64
+ * ```
65
+ */
66
+ export function defineIdentityPlugin(
67
+ name: string,
68
+ apply: <S extends AnyServer>(app: S) => S,
69
+ ): IdentityPlugin {
70
+ return Object.assign(apply, { pluginName: name }) as IdentityPlugin
71
+ }
72
+
73
+ /**
74
+ * Alias of {@link defineIdentityPlugin} with a name that says what it's FOR: a plugin that **mounts
75
+ * routes/hooks but adds no context type** (an auth router, an audit logger). Use this - not
76
+ * {@link definePlugin} - for any such plugin, or the typed client silently collapses to `any`. The
77
+ * "identity" in {@link defineIdentityPlugin} refers to the type-identity it preserves; `defineRouterPlugin`
78
+ * is the same thing under a clearer name.
79
+ *
80
+ * Mount routes as a **side effect**, then return the app unchanged (registering via `.get`/`.post` would
81
+ * change the type away from the identity `S`; the mounted routes run but aren't in the caller's typed
82
+ * registry - that's the trade that keeps everything else typed):
83
+ *
84
+ * ```ts
85
+ * export const scim = defineRouterPlugin("scim", (app) => {
86
+ * app.get("/scim/v2/Users", listUsers) // side effect: mounted at runtime
87
+ * return app // return S unchanged → routes added after .use(scim) stay typed
88
+ * })
89
+ * const api = server().get("/a", h).use(scim).get("/b", h) // /a AND /b stay typed
90
+ * ```
91
+ */
92
+ export const defineRouterPlugin: typeof defineIdentityPlugin = defineIdentityPlugin
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Query-string and urlencoded-form parsing. Pure request-shaping helpers with no server internals:
3
+ * the fast scanner for `c.query`, the repeated-key -> `string[]` promotion shared by query + form,
4
+ * and the byte-capped HTML-form reader. Split out of the server kernel so the file stays one domain.
5
+ */
6
+ import { readBoundedBytes } from "./body.ts"
7
+ import { jsonError, urlPartsOf } from "./http.ts"
8
+ import type { RequestSource } from "./server.ts"
9
+
10
+ // The query string ("?a=1", or "" when absent) - for lazily building `c.query` only when read.
11
+ // The fragment (after the first `#`) bounds the search: a `?` that appears only inside the fragment
12
+ // is NOT a query (matches WHATWG). Fragments never reach the server in `req.url`, so this is purely
13
+ // for provable equivalence with `new URL(req.url).search`.
14
+ export function searchOf(url: string): string {
15
+ return urlPartsOf(url).search
16
+ }
17
+
18
+ /** A query value: a single occurrence is a string; a repeated key promotes to a string[] so an
19
+ * array query schema (`t.array(t.string())`) can validate `?tag=a&tag=b` - last-wins silently
20
+ * dropped values before (audit 2026-06). Single-occurrence keys stay plain strings, so existing
21
+ * `t.string()` schemas are untouched; a repeated key against a string schema now FAILS validation
22
+ * (an explicit 400 beats silently picking one). */
23
+ export type QueryValue = string | string[]
24
+
25
+ /** Accumulate into a NULL-PROTOTYPE record (every call site below creates one): with no inherited
26
+ * `constructor`/`toString`/`__proto__` accessors, a hostile key is just an own data key - the
27
+ * promotion logic can't collide with `Object.prototype` members, and `__proto__` needs no special
28
+ * case (direct assignment on a null-proto object creates an own property). */
29
+ function setQueryValue(out: Record<string, QueryValue>, key: string, value: string): void {
30
+ const existing = out[key]
31
+ if (existing === undefined) {
32
+ out[key] = value
33
+ } else if (typeof existing === "string") {
34
+ out[key] = [existing, value]
35
+ } else {
36
+ existing.push(value)
37
+ }
38
+ }
39
+
40
+ function queryObjectFallback(search: string): Record<string, QueryValue> {
41
+ // Manual iteration instead of Object.fromEntries: repeated keys must promote to arrays
42
+ // (fromEntries is last-wins), and __proto__ needs the same own-property guard.
43
+ const out: Record<string, QueryValue> = Object.create(null) as Record<string, QueryValue>
44
+ for (const [key, value] of new URLSearchParams(search)) {
45
+ setQueryValue(out, key, value)
46
+ }
47
+ return out
48
+ }
49
+
50
+ /**
51
+ * Build the plain object passed to query schemas. Plain ASCII-ish queries avoid
52
+ * `URLSearchParams` + iterator allocations; encoded queries fall back to the Web API so `+`,
53
+ * percent-decoding, malformed escapes, and empty-key behavior stay exact. Repeated keys promote
54
+ * to `string[]` on BOTH paths (see {@link setQueryValue}).
55
+ */
56
+ // Shared empty result for no-query requests: frozen + null-prototype (same shape contract as the
57
+ // populated path), allocated once instead of per request.
58
+ const EMPTY_QUERY = Object.freeze(Object.create(null)) as Record<string, QueryValue>
59
+
60
+ export function queryObjectOf(search: string): Record<string, QueryValue> {
61
+ const start = search.charCodeAt(0) === 63 /* ? */ ? 1 : 0
62
+ if (start >= search.length) return EMPTY_QUERY
63
+
64
+ for (let i = start; i < search.length; i++) {
65
+ const c = search.charCodeAt(i)
66
+ if (c === 37 /* % */ || c === 43 /* + */) return queryObjectFallback(search)
67
+ }
68
+
69
+ const out: Record<string, QueryValue> = Object.create(null) as Record<string, QueryValue>
70
+ let pos = start
71
+ while (pos <= search.length) {
72
+ const amp = search.indexOf("&", pos)
73
+ const end = amp === -1 ? search.length : amp
74
+ if (end > pos) {
75
+ const eq = search.indexOf("=", pos)
76
+ const split = eq !== -1 && eq < end ? eq : end
77
+ const key = search.slice(pos, split)
78
+ const value = split === end ? "" : search.slice(split + 1, end)
79
+ setQueryValue(out, key, value)
80
+ }
81
+ if (amp === -1) break
82
+ pos = amp + 1
83
+ }
84
+ return out
85
+ }
86
+
87
+ /** `application/x-www-form-urlencoded`, with or without a charset suffix. */
88
+ export function isUrlEncodedForm(contentType: string): boolean {
89
+ return (
90
+ contentType === "application/x-www-form-urlencoded" ||
91
+ contentType.startsWith("application/x-www-form-urlencoded;")
92
+ )
93
+ }
94
+
95
+ const FORM_DECODER = new TextDecoder()
96
+
97
+ /**
98
+ * Read an HTML-form body (urlencoded) into the plain object a body schema validates - same byte
99
+ * cap as the JSON path (a lying/absent Content-Length can't force an oversized buffer), same
100
+ * repeated-key -> `string[]` promotion as query parsing, same `__proto__` guard.
101
+ */
102
+ export async function readBoundedForm(
103
+ req: RequestSource,
104
+ maxBytes: number,
105
+ ): Promise<Record<string, QueryValue> | Response> {
106
+ const read = await readBoundedBytes(req, maxBytes)
107
+ if (!read.ok) {
108
+ return read.status === 413
109
+ ? jsonError(413, "payload_too_large")
110
+ : jsonError(400, "invalid_content_length")
111
+ }
112
+ const out: Record<string, QueryValue> = Object.create(null) as Record<string, QueryValue>
113
+ // URLSearchParams owns the format's quirks (`+` as space, percent-decoding, empty keys);
114
+ // it never throws on junk input, so no try/catch is needed here.
115
+ for (const [key, value] of new URLSearchParams(FORM_DECODER.decode(read.bytes))) {
116
+ setQueryValue(out, key, value)
117
+ }
118
+ return out
119
+ }
@@ -1,4 +1,4 @@
1
- import type { InferOutput, StandardSchemaV1 } from "../schema/standard.ts"
1
+ import type { InferInput, InferOutput, StandardSchemaV1 } from "../schema/standard.ts"
2
2
  import type { Params, RouteSchema } from "./context.ts"
3
3
 
4
4
  /**
@@ -16,12 +16,17 @@ export interface RouteInfo {
16
16
  readonly query: unknown
17
17
  readonly body: unknown
18
18
  readonly output: unknown
19
- /** Union of the route's declared error-response body types (from `schema.errors`); `unknown` when the
20
- * route declares none. Surfaced by the typed client as the failure `errorData`. */
19
+ /** The route's declared error bodies as a status-keyed record (from `schema.errors`, e.g.
20
+ * `{ 404: NotFound }`); `unknown` when the route declares none. The typed client turns this into
21
+ * a status-discriminated failure union. */
21
22
  readonly errors?: unknown
22
23
  /** The SSE event payload type (from `schema.sse`, declared via `app.sse()`); `never` for ordinary
23
24
  * routes. The typed client keys `.subscribe()` availability and its event type off this. */
24
25
  readonly sse?: unknown
26
+ /** The WebSocket message contract (from `app.ws()`'s `messageSchema`/`sendSchema`): `in` is what the
27
+ * client may send, `out` what the server pushes. Present only on WS entries (method key `"WS"`);
28
+ * the typed client keys `.ws()` availability and its frame types off this. */
29
+ readonly ws?: unknown
25
30
  }
26
31
 
27
32
  /** The accumulated, type-level map of every route on a Server: path → method → RouteInfo. */
@@ -46,12 +51,13 @@ type RegistryOutput<S extends RouteSchema, Output> = S extends {
46
51
  ? InferOutput<R>
47
52
  : Output
48
53
 
49
- /** The union of a route's declared error-response body types (`schema.errors` → `{status: schema}`), so the
50
- * typed client can surface the failure body. `unknown` when the route declares no `errors`. */
54
+ /** A route's declared error-response body types as a STATUS-KEYED record (`schema.errors` →
55
+ * `{ 404: NotFound, 409: Conflict }`), so the typed client can discriminate the failure body by
56
+ * `status`, not just surface a union. `unknown` when the route declares no `errors`. */
51
57
  type RegistryErrors<S extends RouteSchema> = S extends {
52
58
  errors: infer E extends Record<number, StandardSchemaV1>
53
59
  }
54
- ? { [K in keyof E]: E[K] extends StandardSchemaV1 ? InferOutput<E[K]> : never }[keyof E]
60
+ ? { [K in keyof E]: E[K] extends StandardSchemaV1 ? InferOutput<E[K]> : never }
55
61
  : unknown
56
62
 
57
63
  /** The SSE event payload type from a route's `sse` schema; `never` for ordinary routes. */
@@ -75,6 +81,30 @@ export type OutputOf<H extends (...args: never[]) => unknown> = Exclude<
75
81
  Response
76
82
  >
77
83
 
84
+ /**
85
+ * The registry entry for a WebSocket route (stored under the pseudo-method key `"WS"`, so WS routes
86
+ * ride the same path → method map as HTTP routes - no extra Server generic). `in` is the client →
87
+ * server frame type: the `messageSchema`'s INPUT side, since that is what goes on the wire before
88
+ * validation transforms it. `out` is the server → client frame type from `sendSchema` (a type-level
89
+ * contract; the server's own sends are not runtime-validated). Either is `unknown` when undeclared.
90
+ */
91
+ export type WsRouteInfoFor<
92
+ Path extends string,
93
+ In extends StandardSchemaV1 | undefined,
94
+ Out extends StandardSchemaV1 | undefined,
95
+ > = {
96
+ readonly params: Params<Path>
97
+ readonly query: never
98
+ readonly body: never
99
+ readonly output: never
100
+ readonly errors: unknown
101
+ readonly sse: never
102
+ readonly ws: {
103
+ readonly in: In extends StandardSchemaV1 ? InferInput<In> : unknown
104
+ readonly out: Out extends StandardSchemaV1 ? InferOutput<Out> : unknown
105
+ }
106
+ }
107
+
78
108
  /**
79
109
  * Merge a new route into the registry, combining methods that share a path.
80
110
  *