@nifrajs/core 1.13.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 -35
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -31
- 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/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 +14 -4
- package/dist/server/contract.d.ts.map +1 -1
- package/dist/server/contract.js +8 -2
- 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 +107 -345
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +214 -831
- 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 +77 -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 -133
- package/src/mcp.ts +65 -0
- package/src/node-direct.ts +37 -0
- package/src/reflection.ts +7 -0
- package/src/server/client-ip.ts +60 -0
- package/src/server/context.ts +50 -2
- package/src/server/contract.ts +26 -7
- 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 +402 -1314
- 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
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|