@nifrajs/core 1.12.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (167) hide show
  1. package/README.md +10 -1
  2. package/dist/assurance.d.ts +11 -2
  3. package/dist/assurance.d.ts.map +1 -1
  4. package/dist/assurance.js +20 -0
  5. package/dist/assurance.js.map +1 -1
  6. package/dist/effect-ledger.d.ts +9 -0
  7. package/dist/effect-ledger.d.ts.map +1 -0
  8. package/dist/effect-ledger.js +8 -0
  9. package/dist/effect-ledger.js.map +1 -0
  10. package/dist/idempotency-plugin.d.ts +8 -0
  11. package/dist/idempotency-plugin.d.ts.map +1 -0
  12. package/dist/idempotency-plugin.js +8 -0
  13. package/dist/idempotency-plugin.js.map +1 -0
  14. package/dist/index.d.ts +6 -34
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +6 -30
  17. package/dist/index.js.map +1 -1
  18. package/dist/mcp.d.ts +7 -0
  19. package/dist/mcp.d.ts.map +1 -0
  20. package/dist/mcp.js +56 -0
  21. package/dist/mcp.js.map +1 -0
  22. package/dist/node-direct.d.ts +5 -0
  23. package/dist/node-direct.d.ts.map +1 -0
  24. package/dist/node-direct.js +27 -0
  25. package/dist/node-direct.js.map +1 -0
  26. package/dist/reflection.d.ts +5 -0
  27. package/dist/reflection.d.ts.map +1 -1
  28. package/dist/reflection.js +2 -0
  29. package/dist/reflection.js.map +1 -1
  30. package/dist/router/pattern.d.ts +42 -0
  31. package/dist/router/pattern.d.ts.map +1 -0
  32. package/dist/router/pattern.js +127 -0
  33. package/dist/router/pattern.js.map +1 -0
  34. package/dist/router/router.d.ts +2 -1
  35. package/dist/router/router.d.ts.map +1 -1
  36. package/dist/router/router.js +11 -48
  37. package/dist/router/router.js.map +1 -1
  38. package/dist/server/client-ip.d.ts +35 -0
  39. package/dist/server/client-ip.d.ts.map +1 -0
  40. package/dist/server/client-ip.js +42 -0
  41. package/dist/server/client-ip.js.map +1 -0
  42. package/dist/server/context.d.ts +47 -2
  43. package/dist/server/context.d.ts.map +1 -1
  44. package/dist/server/contract.d.ts +37 -10
  45. package/dist/server/contract.d.ts.map +1 -1
  46. package/dist/server/contract.js +33 -8
  47. package/dist/server/contract.js.map +1 -1
  48. package/dist/server/http.d.ts +14 -0
  49. package/dist/server/http.d.ts.map +1 -0
  50. package/dist/server/http.js +45 -0
  51. package/dist/server/http.js.map +1 -0
  52. package/dist/server/idempotency-lane.d.ts +44 -0
  53. package/dist/server/idempotency-lane.d.ts.map +1 -0
  54. package/dist/server/idempotency-lane.js +181 -0
  55. package/dist/server/idempotency-lane.js.map +1 -0
  56. package/dist/server/install.d.ts +21 -0
  57. package/dist/server/install.d.ts.map +1 -0
  58. package/dist/server/install.js +21 -0
  59. package/dist/server/install.js.map +1 -0
  60. package/dist/server/ledger-lane.d.ts +53 -0
  61. package/dist/server/ledger-lane.d.ts.map +1 -0
  62. package/dist/server/ledger-lane.js +82 -0
  63. package/dist/server/ledger-lane.js.map +1 -0
  64. package/dist/server/mcp-hook.d.ts +28 -0
  65. package/dist/server/mcp-hook.d.ts.map +1 -0
  66. package/dist/server/mcp-hook.js +2 -0
  67. package/dist/server/mcp-hook.js.map +1 -0
  68. package/dist/server/node-outcome-hook.d.ts +9 -0
  69. package/dist/server/node-outcome-hook.d.ts.map +1 -0
  70. package/dist/server/node-outcome-hook.js +2 -0
  71. package/dist/server/node-outcome-hook.js.map +1 -0
  72. package/dist/server/node-outcome.d.ts +37 -0
  73. package/dist/server/node-outcome.d.ts.map +1 -0
  74. package/dist/server/node-outcome.js +75 -0
  75. package/dist/server/node-outcome.js.map +1 -0
  76. package/dist/server/plugin.d.ts +78 -0
  77. package/dist/server/plugin.d.ts.map +1 -0
  78. package/dist/server/plugin.js +57 -0
  79. package/dist/server/plugin.js.map +1 -0
  80. package/dist/server/query.d.ts +18 -0
  81. package/dist/server/query.d.ts.map +1 -0
  82. package/dist/server/query.js +102 -0
  83. package/dist/server/query.js.map +1 -0
  84. package/dist/server/registry.d.ts +31 -6
  85. package/dist/server/registry.d.ts.map +1 -1
  86. package/dist/server/request-context.d.ts +51 -0
  87. package/dist/server/request-context.d.ts.map +1 -0
  88. package/dist/server/request-context.js +184 -0
  89. package/dist/server/request-context.js.map +1 -0
  90. package/dist/server/respond.d.ts +17 -0
  91. package/dist/server/respond.d.ts.map +1 -0
  92. package/dist/server/respond.js +81 -0
  93. package/dist/server/respond.js.map +1 -0
  94. package/dist/server/runtime-core.d.ts +36 -0
  95. package/dist/server/runtime-core.d.ts.map +1 -0
  96. package/dist/server/runtime-core.js +51 -0
  97. package/dist/server/runtime-core.js.map +1 -0
  98. package/dist/server/server-types.d.ts +203 -0
  99. package/dist/server/server-types.d.ts.map +1 -0
  100. package/dist/server/server-types.js +2 -0
  101. package/dist/server/server-types.js.map +1 -0
  102. package/dist/server/server.d.ts +135 -358
  103. package/dist/server/server.d.ts.map +1 -1
  104. package/dist/server/server.js +476 -1065
  105. package/dist/server/server.js.map +1 -1
  106. package/dist/server/sse-hook.d.ts +10 -0
  107. package/dist/server/sse-hook.d.ts.map +1 -0
  108. package/dist/server/sse-hook.js +2 -0
  109. package/dist/server/sse-hook.js.map +1 -0
  110. package/dist/server/sse.d.ts +7 -23
  111. package/dist/server/sse.d.ts.map +1 -1
  112. package/dist/server/sse.js +18 -0
  113. package/dist/server/sse.js.map +1 -1
  114. package/dist/server/websocket.d.ts +17 -1
  115. package/dist/server/websocket.d.ts.map +1 -1
  116. package/dist/server/websocket.js.map +1 -1
  117. package/dist/server/ws-hook.d.ts +8 -11
  118. package/dist/server/ws-hook.d.ts.map +1 -1
  119. package/dist/server/ws-hook.js +7 -14
  120. package/dist/server/ws-hook.js.map +1 -1
  121. package/dist/server.d.ts +21 -0
  122. package/dist/server.d.ts.map +1 -0
  123. package/dist/server.js +16 -0
  124. package/dist/server.js.map +1 -0
  125. package/dist/ws.d.ts +16 -7
  126. package/dist/ws.d.ts.map +1 -1
  127. package/dist/ws.js +25 -10
  128. package/dist/ws.js.map +1 -1
  129. package/package.json +82 -6
  130. package/src/assurance.ts +36 -1
  131. package/src/effect-ledger.ts +9 -0
  132. package/src/idempotency-plugin.ts +7 -0
  133. package/src/index.ts +6 -132
  134. package/src/mcp.ts +65 -0
  135. package/src/node-direct.ts +37 -0
  136. package/src/reflection.ts +7 -0
  137. package/src/router/pattern.ts +176 -0
  138. package/src/router/router.ts +11 -69
  139. package/src/server/client-ip.ts +60 -0
  140. package/src/server/context.ts +50 -2
  141. package/src/server/contract.ts +68 -25
  142. package/src/server/http.ts +58 -0
  143. package/src/server/idempotency-lane.ts +287 -0
  144. package/src/server/install.ts +28 -0
  145. package/src/server/ledger-lane.ts +125 -0
  146. package/src/server/mcp-hook.ts +41 -0
  147. package/src/server/node-outcome-hook.ts +9 -0
  148. package/src/server/node-outcome.ts +112 -0
  149. package/src/server/plugin.ts +92 -0
  150. package/src/server/query.ts +119 -0
  151. package/src/server/registry.ts +36 -6
  152. package/src/server/request-context.ts +271 -0
  153. package/src/server/respond.ts +93 -0
  154. package/src/server/runtime-core.ts +74 -0
  155. package/src/server/server-types.ts +204 -0
  156. package/src/server/server.ts +863 -1657
  157. package/src/server/sse-hook.ts +14 -0
  158. package/src/server/sse.ts +26 -0
  159. package/src/server/websocket.ts +20 -0
  160. package/src/server/ws-hook.ts +8 -19
  161. package/src/server.ts +92 -0
  162. package/src/ws.ts +33 -10
  163. package/dist/invariants.d.ts +0 -70
  164. package/dist/invariants.d.ts.map +0 -1
  165. package/dist/invariants.js +0 -487
  166. package/dist/invariants.js.map +0 -1
  167. package/src/invariants.ts +0 -604
@@ -4,7 +4,7 @@ import { normalizeRouteCapabilities } from "../internal/capability-runtime.ts"
4
4
  import { METHODS, type Method } from "../router/router.ts"
5
5
  import type { InferOutput, StandardSchemaV1 } from "../schema/standard.ts"
6
6
  import type { Context, IdempotencyConfig, Params, RouteSchema } from "./context.ts"
7
- import type { OutputOf } from "./registry.ts"
7
+ import type { EmptyRegistry, OutputOf, Registry } from "./registry.ts"
8
8
  import { Server } from "./server.ts"
9
9
 
10
10
  /** An additional (non-success) response a contract operation can document, e.g. a `404`. */
@@ -26,6 +26,8 @@ export interface ResponseDef {
26
26
  export interface OperationDef {
27
27
  readonly method: Method
28
28
  readonly path: string
29
+ /** Optional path-params schema (validated + coercible at the boundary; see {@link RouteSchema.params}). */
30
+ readonly params?: StandardSchemaV1
29
31
  readonly body?: StandardSchemaV1
30
32
  readonly query?: StandardSchemaV1
31
33
  readonly response?: StandardSchemaV1
@@ -35,6 +37,10 @@ export interface OperationDef {
35
37
  readonly idempotency?: IdempotencyConfig
36
38
  /** Highest data-sensitivity the response carries (see {@link RouteSchema.classification}). */
37
39
  readonly classification?: DataClassification
40
+ /** Inline route-assurance evidence this operation carries (see {@link RouteSchema.assurance}). */
41
+ readonly assurance?: readonly string[]
42
+ /** Mark this operation a dynamic route family (see {@link RouteSchema.family}). */
43
+ readonly family?: boolean
38
44
  /** Short summary (OpenAPI `summary`). */
39
45
  readonly summary?: string
40
46
  /** Longer description (OpenAPI `description`, CommonMark). */
@@ -68,21 +74,27 @@ type OpResponse<O extends OperationDef> = O extends { response: infer R extends
68
74
 
69
75
  /** The body types of a contract op's declared **non-2xx** `responses` (schema-bearing ones), keyed by status. */
70
76
  type OpErrorBodies<Rs extends Record<string, ResponseDef>> = {
71
- [K in keyof Rs as K extends `2${string}` ? never : K]: Rs[K] extends {
77
+ // Non-2xx statuses, re-keyed from the contract's string keys ("404") to number literals (404) so
78
+ // the client's failure union discriminates on the numeric `status` it actually receives.
79
+ [K in keyof Rs as K extends `2${string}`
80
+ ? never
81
+ : K extends `${infer N extends number}`
82
+ ? N
83
+ : never]: Rs[K] extends {
72
84
  schema: infer S extends StandardSchemaV1
73
85
  }
74
86
  ? InferOutput<S>
75
87
  : never
76
88
  }
77
89
 
78
- /** The client-visible error union for a contract op — the union of its non-2xx `responses` body schemas,
79
- * or `unknown` when it declares none (mirrors an inline route with no `errors`). */
90
+ /** The client-visible error bodies for a contract op as a status-keyed record (`{ 404: Body }`), or
91
+ * `unknown` when it declares none (mirrors an inline route with no `errors`). */
80
92
  type OpErrors<O extends OperationDef> = O extends {
81
93
  responses: infer Rs extends Record<string, ResponseDef>
82
94
  }
83
- ? [OpErrorBodies<Rs>[keyof OpErrorBodies<Rs>]] extends [never]
95
+ ? [keyof OpErrorBodies<Rs>] extends [never]
84
96
  ? unknown
85
- : OpErrorBodies<Rs>[keyof OpErrorBodies<Rs>]
97
+ : OpErrorBodies<Rs>
86
98
  : unknown
87
99
 
88
100
  /**
@@ -116,7 +128,8 @@ export type RegistryFor<C extends ContractShape> = {
116
128
  type SchemaForOp<O extends OperationDef> = (O extends { body: infer B extends StandardSchemaV1 }
117
129
  ? { body: B }
118
130
  : Record<never, never>) &
119
- (O extends { query: infer Q extends StandardSchemaV1 } ? { query: Q } : Record<never, never>)
131
+ (O extends { query: infer Q extends StandardSchemaV1 } ? { query: Q } : Record<never, never>) &
132
+ (O extends { params: infer P extends StandardSchemaV1 } ? { params: P } : Record<never, never>)
120
133
 
121
134
  /**
122
135
  * The handler context for an op — identical to the inline `Context<Path, S>`, so a
@@ -140,9 +153,13 @@ type HandlerReturnForOp<O extends OperationDef> = O extends {
140
153
  ? Response | InferOutput<R>
141
154
  : unknown
142
155
 
143
- /** The handlers `implement` requires: one per operation, typed from the op's input + response contract. */
144
- export type HandlersFor<C extends ContractShape> = {
145
- [K in keyof C]: (context: ContextForOp<C[K]>) => MaybePromise<HandlerReturnForOp<C[K]>>
156
+ /**
157
+ * The handlers `implement` requires: one per operation, typed from the op's input + response contract,
158
+ * intersected with the host app's accumulated `derive`/`decorate` context - the same
159
+ * `Context & Ctx` an inline {@link Handler} receives, so a handler graduates either way unchanged.
160
+ */
161
+ export type HandlersFor<C extends ContractShape, Ctx = NonNullable<unknown>> = {
162
+ [K in keyof C]: (context: ContextForOp<C[K]> & Ctx) => MaybePromise<HandlerReturnForOp<C[K]>>
146
163
  }
147
164
 
148
165
  const METHOD_SET: ReadonlySet<string> = new Set(METHODS)
@@ -189,7 +206,11 @@ type AnyFn = (...args: never[]) => unknown
189
206
  * the implemented server stays route-for-route identical to the equivalent inline server (the
190
207
  * mode-conformance guarantee), and a contract-typed client and a `typeof app`-typed client agree.
191
208
  */
192
- export type RegistryFromImpl<C extends ContractShape, H extends HandlersFor<C>> = {
209
+ export type RegistryFromImpl<
210
+ C extends ContractShape,
211
+ H extends HandlersFor<C, Ctx>,
212
+ Ctx = NonNullable<unknown>,
213
+ > = {
193
214
  [P in C[keyof C]["path"]]: {
194
215
  [K in keyof C as C[K]["path"] extends P ? C[K]["method"] : never]: {
195
216
  readonly params: Params<C[K]["path"]>
@@ -215,42 +236,64 @@ export type RegistryFromImpl<C extends ContractShape, H extends HandlersFor<C>>
215
236
  * builder, so the result is identical to writing the routes inline — handlers
216
237
  * lift over **unchanged** ("graduation"), and body/query schemas validate at the
217
238
  * request boundary exactly as in inline mode.
239
+ *
240
+ * Pass a pre-configured `app` to give the contract's routes a middleware chain. A route captures the
241
+ * server's `derive`/`decorate`/assurance chain **at registration**, so anything applied to the
242
+ * returned server afterwards reaches the contract's routes not at all - the app must already carry it:
243
+ *
244
+ * ```ts
245
+ * const app = implement(contract, handlers, server().use(auth).derive(sessionOf))
246
+ * ```
247
+ *
248
+ * That is also the seam that lets `nifra assure` prove rather than merely classify a contract-first
249
+ * app: the plugin that installs the enforcement is what declares the evidence
250
+ * ({@link withRouteAssurance}), and only a plugin installed *before* registration is captured. Handlers
251
+ * then see the app's `Ctx`, and the returned server keeps any routes the app already had.
218
252
  */
219
- export function implement<const C extends ContractShape, H extends HandlersFor<C>>(
220
- contract: C,
221
- handlers: H,
222
- ): Server<RegistryFromImpl<C, H>> {
223
- const app = new Server()
224
- for (const [name, op] of Object.entries(contract)) {
253
+ export function implement<
254
+ const C extends ContractShape,
255
+ H extends HandlersFor<C, Ctx>,
256
+ R extends Registry = EmptyRegistry,
257
+ Ctx = NonNullable<unknown>,
258
+ >(contract: C, handlers: H, app?: Server<R, Ctx>): Server<R & RegistryFromImpl<C, H, Ctx>, Ctx> {
259
+ const target = (app ?? new Server()) as Server<R, Ctx>
260
+ const routes = Object.entries(contract).map(([name, op]) => {
225
261
  // body/query are validated at the request boundary; `response` is a type + introspection contract
226
262
  // ONLY — never validated at runtime and never read on the request hot path (the lifecycle reads
227
263
  // schema.body/query by name; the `bare`/bodyOnly/queryOnly fast-path gates ignore `response`). Built
228
264
  // once here at bind time, not per request — it just carries a reference to the op's existing schema.
229
265
  // A response-less op still yields `undefined`, byte-identical to before.
230
266
  const schema: RouteSchema | undefined =
267
+ op.params !== undefined ||
231
268
  op.body !== undefined ||
232
269
  op.query !== undefined ||
233
270
  op.response !== undefined ||
234
271
  op.capabilities !== undefined ||
235
272
  op.idempotency !== undefined ||
236
- op.classification !== undefined
273
+ op.classification !== undefined ||
274
+ op.assurance !== undefined ||
275
+ op.family !== undefined
237
276
  ? {
277
+ ...(op.params !== undefined ? { params: op.params } : {}),
238
278
  ...(op.body !== undefined ? { body: op.body } : {}),
239
279
  ...(op.query !== undefined ? { query: op.query } : {}),
240
280
  ...(op.response !== undefined ? { response: op.response } : {}),
241
281
  ...(op.capabilities !== undefined ? { capabilities: op.capabilities } : {}),
242
282
  ...(op.idempotency !== undefined ? { idempotency: op.idempotency } : {}),
243
283
  ...(op.classification !== undefined ? { classification: op.classification } : {}),
284
+ ...(op.assurance !== undefined ? { assurance: op.assurance } : {}),
285
+ ...(op.family !== undefined ? { family: op.family } : {}),
244
286
  }
245
287
  : undefined
246
- app.register(
247
- op.method,
248
- op.path,
288
+ return {
289
+ method: op.method,
290
+ path: op.path,
249
291
  schema,
250
- handlers[name as keyof H] as (context: never) => unknown,
251
- )
252
- }
292
+ handler: handlers[name as keyof H] as (context: never) => unknown,
293
+ }
294
+ })
295
+ target.registerBatch(routes)
253
296
  // Runtime registered exactly the contract's routes through the inline path; the
254
297
  // registry type is computed from the contract inputs + handler return types.
255
- return app as unknown as Server<RegistryFromImpl<C, H>>
298
+ return target as unknown as Server<R & RegistryFromImpl<C, H, Ctx>, Ctx>
256
299
  }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Dependency-free HTTP helpers shared by the kernel and the opt-in request lanes (idempotency,
3
+ * effect-ledger). Kept in a leaf module so a lane can reuse them without importing the server, and so
4
+ * the server can reuse them without pulling a lane's feature code into the base bundle.
5
+ */
6
+
7
+ /** A uniform JSON error envelope: `{ ok: false, error }` at the given status. */
8
+ export function jsonError(
9
+ status: number,
10
+ error: string,
11
+ headers?: Record<string, string>,
12
+ ): Response {
13
+ return Response.json(
14
+ { ok: false, error },
15
+ headers !== undefined ? { status, headers } : { status },
16
+ )
17
+ }
18
+
19
+ export interface UrlParts {
20
+ readonly pathname: string
21
+ readonly search: string
22
+ }
23
+
24
+ // Extract pathname + query WITHOUT a full WHATWG `new URL(req.url)` parse.
25
+ // `req.url` from every supported runtime is an absolute, already-normalized URL, so the pathname is
26
+ // the substring after `scheme://host[:port]` up to `?`/`#`. Query-schema routes also need the search
27
+ // string; parsing both in one scanner avoids the old `pathnameOf()` + `searchOf()` double scan.
28
+ export function urlPartsOf(url: string): UrlParts {
29
+ const schemeEnd = url.indexOf("://")
30
+ const start = schemeEnd === -1 ? url.indexOf("/") : url.indexOf("/", schemeEnd + 3)
31
+ if (start === -1) return { pathname: "/", search: "" }
32
+
33
+ let pathEnd = url.length
34
+ let searchStart = -1
35
+ let searchEnd = url.length
36
+ for (let i = start; i < url.length; i++) {
37
+ const c = url.charCodeAt(i)
38
+ if (c === 63 /* ? */ && searchStart === -1) {
39
+ pathEnd = i
40
+ searchStart = i
41
+ } else if (c === 35 /* # */) {
42
+ if (searchStart === -1) pathEnd = i
43
+ searchEnd = i
44
+ break
45
+ }
46
+ }
47
+
48
+ return {
49
+ pathname: url.slice(start, pathEnd),
50
+ search: searchStart === -1 ? "" : url.slice(searchStart, searchEnd),
51
+ }
52
+ }
53
+
54
+ // Extract the pathname WITHOUT a full WHATWG `new URL(req.url)` parse. Kept as a public-ish helper
55
+ // for tests and callers that only need the path; the request hot path uses `urlPartsOf()` once.
56
+ export function pathnameOf(url: string): string {
57
+ return urlPartsOf(url).pathname
58
+ }
@@ -0,0 +1,287 @@
1
+ /**
2
+ * The opt-in idempotency request lane. Installed by `.use(idempotency())` and reached only by routes
3
+ * that declare `schema.idempotency`, so the kernel never statically imports this code: a bare
4
+ * `server()` tree-shakes the whole dedupe machinery out.
5
+ *
6
+ * The kernel resolves a route's declared idempotency at registration (via {@link IdempotencyRuntime.resolve})
7
+ * and, on the request path, hands a matched idempotent route to {@link IdempotencyRuntime.run}. Everything
8
+ * server-specific is supplied through {@link IdempotencyHost} so this module depends on no server internals.
9
+ */
10
+ import { RouteConfigError } from "../errors.ts"
11
+ import {
12
+ canonicalizeIdempotencyBody,
13
+ computeIdempotencyFingerprint,
14
+ DEFAULT_IDEMPOTENCY_HEADER,
15
+ DEFAULT_IDEMPOTENCY_TTL_MS,
16
+ IdempotencyResponseTooLargeError,
17
+ type IdempotencyStore,
18
+ MemoryIdempotencyStore,
19
+ responseFromStored,
20
+ serializeResponse,
21
+ validIdempotencyKey,
22
+ validIdempotencyNamespace,
23
+ } from "../idempotency.ts"
24
+ import { readBoundedBytes } from "./body.ts"
25
+ import type { Platform, RouteSchema } from "./context.ts"
26
+ import { jsonError, urlPartsOf } from "./http.ts"
27
+ import { INSTALL_IDEMPOTENCY } from "./install.ts"
28
+ import type { AnyServer, IdentityPlugin } from "./server.ts"
29
+
30
+ /** Registration-resolved idempotency for a route: the config with its store + defaults pinned. */
31
+ export interface ResolvedIdempotency {
32
+ readonly store: IdempotencyStore
33
+ readonly ttlMs: number
34
+ readonly headerName: string
35
+ readonly namespace: NonNullable<RouteSchema["idempotency"]>["namespace"]
36
+ readonly maxResponseBytes: number
37
+ }
38
+
39
+ /**
40
+ * What the dedupe lane needs from the server to run a fresh key: the app-wide body cap and a way to
41
+ * run the buffered request through the route's normal lanes to a concrete `Response`.
42
+ */
43
+ export interface IdempotencyHost {
44
+ readonly maxBodyBytes: number
45
+ runLanes(
46
+ buffered: Request,
47
+ platform: Platform | undefined,
48
+ entry: unknown,
49
+ params: Record<string, string>,
50
+ search: string | undefined,
51
+ ): Promise<Response>
52
+ }
53
+
54
+ /** The injected idempotency implementation the kernel calls through when the plugin is installed. */
55
+ export interface IdempotencyRuntime {
56
+ /** Resolve a route's declared idempotency into a pinned store + defaults, or `undefined` when off. */
57
+ resolve(
58
+ schema: RouteSchema | undefined,
59
+ authenticated: boolean,
60
+ maxBodyBytes: number,
61
+ ): ResolvedIdempotency | undefined
62
+ /** The dedupe lane: read the key, fingerprint the request, consult the store, run/replay/reject. */
63
+ run<T>(
64
+ config: ResolvedIdempotency,
65
+ req: Request,
66
+ platform: Platform | undefined,
67
+ entry: unknown,
68
+ params: Record<string, string>,
69
+ search: string | undefined,
70
+ wrapResponse: (response: Response) => T,
71
+ host: IdempotencyHost,
72
+ ): Promise<T>
73
+ }
74
+
75
+ /** The install seam a server exposes so the `idempotency()` plugin can hand it a runtime. */
76
+ interface IdempotencyInstallable {
77
+ [INSTALL_IDEMPOTENCY](runtime: IdempotencyRuntime): void
78
+ }
79
+
80
+ /**
81
+ * Enable request idempotency. Routes that declare `schema.idempotency` get the dedupe lane: a repeat
82
+ * `Idempotency-Key` replays the stored response instead of re-running the handler. Without this plugin,
83
+ * declaring `schema.idempotency` is a registration error (the safety gate can never be silently
84
+ * dropped). `store` is the app-wide default backing routes that do not pin their own; omit it for a
85
+ * shared in-process store (development/tests only - inject a durable store in production).
86
+ */
87
+ export interface IdempotencyPluginOptions {
88
+ readonly store?: IdempotencyStore | undefined
89
+ }
90
+
91
+ export function idempotency(options?: IdempotencyPluginOptions): IdentityPlugin {
92
+ const runtime = createIdempotencyRuntime(options)
93
+ const apply = <S extends AnyServer>(app: S): S => {
94
+ ;(app as unknown as IdempotencyInstallable)[INSTALL_IDEMPOTENCY](runtime)
95
+ return app
96
+ }
97
+ return Object.assign(apply, { pluginName: "nifra:idempotency" }) as IdentityPlugin
98
+ }
99
+
100
+ const RESPONSE_2XX_LOWER = 200
101
+ const RESPONSE_2XX_UPPER = 300
102
+
103
+ /**
104
+ * Build an idempotency runtime. `store` is the app-wide default backing routes that declare
105
+ * `schema.idempotency` without their own `store`; when omitted, a shared in-process
106
+ * {@link MemoryIdempotencyStore} is created lazily on first idempotent route.
107
+ */
108
+ export function createIdempotencyRuntime(options?: IdempotencyPluginOptions): IdempotencyRuntime {
109
+ const configuredStore = options?.store
110
+ let lazyStore: IdempotencyStore | undefined
111
+ const defaultStore = (): IdempotencyStore => {
112
+ if (configuredStore !== undefined) return configuredStore
113
+ lazyStore ??= new MemoryIdempotencyStore()
114
+ return lazyStore
115
+ }
116
+
117
+ return {
118
+ resolve(schema, authenticated, maxBodyBytes) {
119
+ const config = schema?.idempotency
120
+ if (config === undefined) return undefined
121
+ if (schema?.sse !== undefined) {
122
+ throw new RouteConfigError(
123
+ "INVALID_IDEMPOTENCY",
124
+ "streaming/SSE responses cannot be used on an idempotency route",
125
+ )
126
+ }
127
+ const store = config.store ?? defaultStore()
128
+ if (config.scope === "durable" && store.durability !== "durable") {
129
+ throw new RouteConfigError(
130
+ "INVALID_IDEMPOTENCY",
131
+ 'durable idempotency requires a durable store (store.durability must be "durable")',
132
+ )
133
+ }
134
+ const ttlMs = config.ttlMs ?? DEFAULT_IDEMPOTENCY_TTL_MS
135
+ if (!Number.isSafeInteger(ttlMs) || ttlMs <= 0) {
136
+ throw new RouteConfigError(
137
+ "INVALID_IDEMPOTENCY",
138
+ "idempotency ttlMs must be a finite positive number",
139
+ )
140
+ }
141
+ const headerName = (config.headerName ?? DEFAULT_IDEMPOTENCY_HEADER).toLowerCase()
142
+ try {
143
+ new Headers().set(headerName, "probe")
144
+ } catch {
145
+ throw new RouteConfigError(
146
+ "INVALID_IDEMPOTENCY",
147
+ `invalid idempotency header name ${JSON.stringify(headerName)}`,
148
+ )
149
+ }
150
+ const namespace = config.namespace
151
+ if (namespace === undefined) {
152
+ throw new RouteConfigError(
153
+ "INVALID_IDEMPOTENCY",
154
+ "idempotency namespace is required; use a principal resolver or an explicit shared scope",
155
+ )
156
+ }
157
+ if (typeof namespace === "string" && !validIdempotencyNamespace(namespace)) {
158
+ throw new RouteConfigError(
159
+ "INVALID_IDEMPOTENCY",
160
+ `invalid idempotency namespace ${JSON.stringify(namespace)}`,
161
+ )
162
+ }
163
+ if (authenticated && typeof namespace !== "function") {
164
+ throw new RouteConfigError(
165
+ "INVALID_IDEMPOTENCY",
166
+ "authenticated idempotency routes require a principal namespace resolver",
167
+ )
168
+ }
169
+ const maxResponseBytes = config.maxResponseBytes ?? maxBodyBytes
170
+ if (!Number.isSafeInteger(maxResponseBytes) || maxResponseBytes < 1) {
171
+ throw new RouteConfigError(
172
+ "INVALID_IDEMPOTENCY",
173
+ "idempotency maxResponseBytes must be a positive integer",
174
+ )
175
+ }
176
+ return Object.freeze({
177
+ store,
178
+ ttlMs,
179
+ headerName,
180
+ namespace,
181
+ maxResponseBytes,
182
+ })
183
+ },
184
+
185
+ async run(config, req, platform, entry, params, search, wrapResponse, host) {
186
+ const key = req.headers.get(config.headerName)
187
+ if (key === null || !validIdempotencyKey(key)) {
188
+ return wrapResponse(jsonError(400, "idempotency_key_required"))
189
+ }
190
+ const namespace =
191
+ typeof config.namespace === "function"
192
+ ? await config.namespace(req.clone(), platform)
193
+ : config.namespace
194
+ if (!validIdempotencyNamespace(namespace)) {
195
+ return wrapResponse(jsonError(500, "idempotency_namespace_invalid"))
196
+ }
197
+ const read = await readBoundedBytes(req, host.maxBodyBytes)
198
+ if (!read.ok) {
199
+ return wrapResponse(
200
+ jsonError(read.status, read.status === 413 ? "payload_too_large" : "bad_request"),
201
+ )
202
+ }
203
+ const url = urlPartsOf(req.url)
204
+ const canonicalBody = canonicalizeIdempotencyBody(read.bytes, req.headers.get("content-type"))
205
+ const fingerprint = await computeIdempotencyFingerprint(
206
+ req.method,
207
+ `${url.pathname}${url.search}`,
208
+ canonicalBody,
209
+ req.headers.get("content-type") ?? "",
210
+ )
211
+
212
+ const begin = await config.store.begin({ namespace, key, fingerprint, ttlMs: config.ttlMs })
213
+ if (begin.state === "replay") {
214
+ return wrapResponse(
215
+ responseFromStored(begin.response, { maxBytes: config.maxResponseBytes }),
216
+ )
217
+ }
218
+ if (begin.state === "mismatch") return wrapResponse(jsonError(409, "idempotency_key_reused"))
219
+ if (begin.state === "in-flight") {
220
+ return wrapResponse(jsonError(409, "idempotency_in_progress", { "Retry-After": "1" }))
221
+ }
222
+ if (begin.state === "capacity") {
223
+ return wrapResponse(jsonError(503, "idempotency_store_capacity", { "Retry-After": "1" }))
224
+ }
225
+ const reservation = begin.reservation
226
+
227
+ // Fresh key: re-expose the buffered body as a Request so the normal lanes can read + validate it,
228
+ // then run those lanes to a concrete Response.
229
+ const bufferedInit: RequestInit = {
230
+ method: req.method,
231
+ headers: req.headers,
232
+ signal: req.signal,
233
+ }
234
+ if (read.bytes.byteLength > 0)
235
+ bufferedInit.body = read.bytes as NonNullable<RequestInit["body"]>
236
+ const buffered = new Request(req.url, bufferedInit)
237
+ let response: Response
238
+ try {
239
+ response = await host.runLanes(buffered, platform, entry, params, search)
240
+ } catch (err) {
241
+ // The lanes never throw (errors resolve to a 500 Response), but stay fail-safe: release the
242
+ // reservation so a retry isn't wedged, then re-throw.
243
+ await config.store.abandon({ namespace, key, reservation })
244
+ throw err
245
+ }
246
+ // Cache only successful responses; release the key on any error so the client can retry.
247
+ if (response.status >= RESPONSE_2XX_LOWER && response.status < RESPONSE_2XX_UPPER) {
248
+ let storedResponse: Awaited<ReturnType<typeof serializeResponse>>
249
+ try {
250
+ storedResponse = await serializeResponse(response, { maxBytes: config.maxResponseBytes })
251
+ } catch (error) {
252
+ if (!(error instanceof IdempotencyResponseTooLargeError)) throw error
253
+ // The effect may already have happened, so never abandon and permit a duplicate execution.
254
+ // Commit a small terminal response under the winning key and return that same response now.
255
+ response = jsonError(507, "idempotency_response_too_large")
256
+ try {
257
+ storedResponse = await serializeResponse(response, {
258
+ maxBytes: config.maxResponseBytes,
259
+ })
260
+ } catch (terminalError) {
261
+ if (!(terminalError instanceof IdempotencyResponseTooLargeError)) throw terminalError
262
+ // Even an intentionally tiny bound must remain truthful. An empty 507 preserves terminal
263
+ // status + replay safety without silently storing more bytes than the route permits.
264
+ response = new Response(null, { status: 507 })
265
+ storedResponse = await serializeResponse(response, {
266
+ maxBytes: config.maxResponseBytes,
267
+ })
268
+ }
269
+ }
270
+ const completed = await config.store.complete({
271
+ namespace,
272
+ key,
273
+ reservation,
274
+ response: storedResponse,
275
+ })
276
+ if (!completed) {
277
+ return wrapResponse(
278
+ jsonError(503, "idempotency_reservation_lost", { "Retry-After": "1" }),
279
+ )
280
+ }
281
+ } else {
282
+ await config.store.abandon({ namespace, key, reservation })
283
+ }
284
+ return wrapResponse(response)
285
+ },
286
+ }
287
+ }
@@ -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
+ }