@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.
- package/README.md +10 -1
- package/dist/assurance.d.ts +11 -2
- package/dist/assurance.d.ts.map +1 -1
- package/dist/assurance.js +20 -0
- package/dist/assurance.js.map +1 -1
- package/dist/effect-ledger.d.ts +9 -0
- package/dist/effect-ledger.d.ts.map +1 -0
- package/dist/effect-ledger.js +8 -0
- package/dist/effect-ledger.js.map +1 -0
- package/dist/idempotency-plugin.d.ts +8 -0
- package/dist/idempotency-plugin.d.ts.map +1 -0
- package/dist/idempotency-plugin.js +8 -0
- package/dist/idempotency-plugin.js.map +1 -0
- package/dist/index.d.ts +6 -34
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -30
- package/dist/index.js.map +1 -1
- package/dist/mcp.d.ts +7 -0
- package/dist/mcp.d.ts.map +1 -0
- package/dist/mcp.js +56 -0
- package/dist/mcp.js.map +1 -0
- package/dist/node-direct.d.ts +5 -0
- package/dist/node-direct.d.ts.map +1 -0
- package/dist/node-direct.js +27 -0
- package/dist/node-direct.js.map +1 -0
- package/dist/reflection.d.ts +5 -0
- package/dist/reflection.d.ts.map +1 -1
- package/dist/reflection.js +2 -0
- package/dist/reflection.js.map +1 -1
- package/dist/router/pattern.d.ts +42 -0
- package/dist/router/pattern.d.ts.map +1 -0
- package/dist/router/pattern.js +127 -0
- package/dist/router/pattern.js.map +1 -0
- package/dist/router/router.d.ts +2 -1
- package/dist/router/router.d.ts.map +1 -1
- package/dist/router/router.js +11 -48
- package/dist/router/router.js.map +1 -1
- package/dist/server/client-ip.d.ts +35 -0
- package/dist/server/client-ip.d.ts.map +1 -0
- package/dist/server/client-ip.js +42 -0
- package/dist/server/client-ip.js.map +1 -0
- package/dist/server/context.d.ts +47 -2
- package/dist/server/context.d.ts.map +1 -1
- package/dist/server/contract.d.ts +37 -10
- package/dist/server/contract.d.ts.map +1 -1
- package/dist/server/contract.js +33 -8
- package/dist/server/contract.js.map +1 -1
- package/dist/server/http.d.ts +14 -0
- package/dist/server/http.d.ts.map +1 -0
- package/dist/server/http.js +45 -0
- package/dist/server/http.js.map +1 -0
- package/dist/server/idempotency-lane.d.ts +44 -0
- package/dist/server/idempotency-lane.d.ts.map +1 -0
- package/dist/server/idempotency-lane.js +181 -0
- package/dist/server/idempotency-lane.js.map +1 -0
- package/dist/server/install.d.ts +21 -0
- package/dist/server/install.d.ts.map +1 -0
- package/dist/server/install.js +21 -0
- package/dist/server/install.js.map +1 -0
- package/dist/server/ledger-lane.d.ts +53 -0
- package/dist/server/ledger-lane.d.ts.map +1 -0
- package/dist/server/ledger-lane.js +82 -0
- package/dist/server/ledger-lane.js.map +1 -0
- package/dist/server/mcp-hook.d.ts +28 -0
- package/dist/server/mcp-hook.d.ts.map +1 -0
- package/dist/server/mcp-hook.js +2 -0
- package/dist/server/mcp-hook.js.map +1 -0
- package/dist/server/node-outcome-hook.d.ts +9 -0
- package/dist/server/node-outcome-hook.d.ts.map +1 -0
- package/dist/server/node-outcome-hook.js +2 -0
- package/dist/server/node-outcome-hook.js.map +1 -0
- package/dist/server/node-outcome.d.ts +37 -0
- package/dist/server/node-outcome.d.ts.map +1 -0
- package/dist/server/node-outcome.js +75 -0
- package/dist/server/node-outcome.js.map +1 -0
- package/dist/server/plugin.d.ts +78 -0
- package/dist/server/plugin.d.ts.map +1 -0
- package/dist/server/plugin.js +57 -0
- package/dist/server/plugin.js.map +1 -0
- package/dist/server/query.d.ts +18 -0
- package/dist/server/query.d.ts.map +1 -0
- package/dist/server/query.js +102 -0
- package/dist/server/query.js.map +1 -0
- package/dist/server/registry.d.ts +31 -6
- package/dist/server/registry.d.ts.map +1 -1
- package/dist/server/request-context.d.ts +51 -0
- package/dist/server/request-context.d.ts.map +1 -0
- package/dist/server/request-context.js +184 -0
- package/dist/server/request-context.js.map +1 -0
- package/dist/server/respond.d.ts +17 -0
- package/dist/server/respond.d.ts.map +1 -0
- package/dist/server/respond.js +81 -0
- package/dist/server/respond.js.map +1 -0
- package/dist/server/runtime-core.d.ts +36 -0
- package/dist/server/runtime-core.d.ts.map +1 -0
- package/dist/server/runtime-core.js +51 -0
- package/dist/server/runtime-core.js.map +1 -0
- package/dist/server/server-types.d.ts +203 -0
- package/dist/server/server-types.d.ts.map +1 -0
- package/dist/server/server-types.js +2 -0
- package/dist/server/server-types.js.map +1 -0
- package/dist/server/server.d.ts +135 -358
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +476 -1065
- package/dist/server/server.js.map +1 -1
- package/dist/server/sse-hook.d.ts +10 -0
- package/dist/server/sse-hook.d.ts.map +1 -0
- package/dist/server/sse-hook.js +2 -0
- package/dist/server/sse-hook.js.map +1 -0
- package/dist/server/sse.d.ts +7 -23
- package/dist/server/sse.d.ts.map +1 -1
- package/dist/server/sse.js +18 -0
- package/dist/server/sse.js.map +1 -1
- package/dist/server/websocket.d.ts +17 -1
- package/dist/server/websocket.d.ts.map +1 -1
- package/dist/server/websocket.js.map +1 -1
- package/dist/server/ws-hook.d.ts +8 -11
- package/dist/server/ws-hook.d.ts.map +1 -1
- package/dist/server/ws-hook.js +7 -14
- package/dist/server/ws-hook.js.map +1 -1
- package/dist/server.d.ts +21 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +16 -0
- package/dist/server.js.map +1 -0
- package/dist/ws.d.ts +16 -7
- package/dist/ws.d.ts.map +1 -1
- package/dist/ws.js +25 -10
- package/dist/ws.js.map +1 -1
- package/package.json +82 -6
- package/src/assurance.ts +36 -1
- package/src/effect-ledger.ts +9 -0
- package/src/idempotency-plugin.ts +7 -0
- package/src/index.ts +6 -132
- package/src/mcp.ts +65 -0
- package/src/node-direct.ts +37 -0
- package/src/reflection.ts +7 -0
- package/src/router/pattern.ts +176 -0
- package/src/router/router.ts +11 -69
- package/src/server/client-ip.ts +60 -0
- package/src/server/context.ts +50 -2
- package/src/server/contract.ts +68 -25
- package/src/server/http.ts +58 -0
- package/src/server/idempotency-lane.ts +287 -0
- package/src/server/install.ts +28 -0
- package/src/server/ledger-lane.ts +125 -0
- package/src/server/mcp-hook.ts +41 -0
- package/src/server/node-outcome-hook.ts +9 -0
- package/src/server/node-outcome.ts +112 -0
- package/src/server/plugin.ts +92 -0
- package/src/server/query.ts +119 -0
- package/src/server/registry.ts +36 -6
- package/src/server/request-context.ts +271 -0
- package/src/server/respond.ts +93 -0
- package/src/server/runtime-core.ts +74 -0
- package/src/server/server-types.ts +204 -0
- package/src/server/server.ts +863 -1657
- package/src/server/sse-hook.ts +14 -0
- package/src/server/sse.ts +26 -0
- package/src/server/websocket.ts +20 -0
- package/src/server/ws-hook.ts +8 -19
- package/src/server.ts +92 -0
- package/src/ws.ts +33 -10
- package/dist/invariants.d.ts +0 -70
- package/dist/invariants.d.ts.map +0 -1
- package/dist/invariants.js +0 -487
- package/dist/invariants.js.map +0 -1
- package/src/invariants.ts +0 -604
package/src/server/contract.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
79
|
-
*
|
|
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
|
-
? [
|
|
95
|
+
? [keyof OpErrorBodies<Rs>] extends [never]
|
|
84
96
|
? unknown
|
|
85
|
-
: 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
|
-
/**
|
|
144
|
-
|
|
145
|
-
|
|
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<
|
|
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<
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|