@nifrajs/core 1.9.1 → 1.11.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/dist/assurance.d.ts +18 -0
- package/dist/assurance.d.ts.map +1 -1
- package/dist/assurance.js +28 -1
- package/dist/assurance.js.map +1 -1
- package/dist/budget.d.ts +91 -0
- package/dist/budget.d.ts.map +1 -0
- package/dist/budget.js +172 -0
- package/dist/budget.js.map +1 -0
- package/dist/capabilities.d.ts +146 -0
- package/dist/capabilities.d.ts.map +1 -0
- package/dist/capabilities.js +283 -0
- package/dist/capabilities.js.map +1 -0
- package/dist/classification.d.ts +39 -0
- package/dist/classification.d.ts.map +1 -0
- package/dist/classification.js +153 -0
- package/dist/classification.js.map +1 -0
- package/dist/errors.d.ts +1 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js.map +1 -1
- package/dist/idempotency.d.ts +137 -0
- package/dist/idempotency.d.ts.map +1 -0
- package/dist/idempotency.js +306 -0
- package/dist/idempotency.js.map +1 -0
- package/dist/index.d.ts +8 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -1
- package/dist/index.js.map +1 -1
- package/dist/internal/capability-runtime.d.ts +16 -0
- package/dist/internal/capability-runtime.d.ts.map +1 -0
- package/dist/internal/capability-runtime.js +27 -0
- package/dist/internal/capability-runtime.js.map +1 -0
- package/dist/internal/route-assurance.d.ts +1 -0
- package/dist/internal/route-assurance.d.ts.map +1 -1
- package/dist/internal/route-assurance.js +1 -0
- package/dist/internal/route-assurance.js.map +1 -1
- package/dist/invariants.d.ts +66 -0
- package/dist/invariants.d.ts.map +1 -0
- package/dist/invariants.js +483 -0
- package/dist/invariants.js.map +1 -0
- package/dist/ledger.d.ts +154 -0
- package/dist/ledger.d.ts.map +1 -0
- package/dist/ledger.js +271 -0
- package/dist/ledger.js.map +1 -0
- package/dist/manifest.d.ts +81 -0
- package/dist/manifest.d.ts.map +1 -0
- package/dist/manifest.js +394 -0
- package/dist/manifest.js.map +1 -0
- package/dist/mount.d.ts +18 -0
- package/dist/mount.d.ts.map +1 -0
- package/dist/mount.js +11 -0
- package/dist/mount.js.map +1 -0
- package/dist/reflection.d.ts +4 -0
- package/dist/reflection.d.ts.map +1 -1
- package/dist/reflection.js +9 -0
- package/dist/reflection.js.map +1 -1
- package/dist/server/context.d.ts +50 -1
- package/dist/server/context.d.ts.map +1 -1
- package/dist/server/contract.d.ts +8 -1
- package/dist/server/contract.d.ts.map +1 -1
- package/dist/server/contract.js +11 -1
- package/dist/server/contract.js.map +1 -1
- package/dist/server/server.d.ts +56 -2
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +294 -17
- package/dist/server/server.js.map +1 -1
- package/package.json +36 -4
- package/src/assurance.ts +48 -1
- package/src/budget.ts +267 -0
- package/src/capabilities.ts +486 -0
- package/src/classification.ts +189 -0
- package/src/errors.ts +2 -0
- package/src/idempotency.ts +420 -0
- package/src/index.ts +8 -1
- package/src/internal/capability-runtime.ts +49 -0
- package/src/internal/route-assurance.ts +1 -0
- package/src/invariants.ts +600 -0
- package/src/ledger.ts +430 -0
- package/src/manifest.ts +543 -0
- package/src/mount.ts +24 -0
- package/src/reflection.ts +18 -0
- package/src/server/context.ts +54 -1
- package/src/server/contract.ts +19 -2
- package/src/server/server.ts +463 -26
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Idempotency primitive. A mutating route can declare `schema.idempotency`; the server then dedupes
|
|
3
|
+
* on an `Idempotency-Key` header: the first request runs and its response is stored, and a retry with
|
|
4
|
+
* the same key replays the stored response byte-for-byte without re-running the handler. A retry that
|
|
5
|
+
* reuses a key with a *different* request body is rejected (409) — a key binds to one request.
|
|
6
|
+
*
|
|
7
|
+
* This module is the runtime-neutral core: the store interface, an in-memory store, the request
|
|
8
|
+
* fingerprint, and response (de)serialization. The server owns the request-path lane that reads the
|
|
9
|
+
* body, consults the store, and captures the response. All logic here is pure/injectable so it is
|
|
10
|
+
* unit-tested without a server. A durable store (cross-restart) implements the same interface.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** Whether a route's idempotency is satisfied by an in-process store or a durable (cross-restart) one. */
|
|
14
|
+
export type IdempotencyScope = "request" | "durable"
|
|
15
|
+
|
|
16
|
+
/** Default retention for a stored idempotent response: 24 hours. */
|
|
17
|
+
export const DEFAULT_IDEMPOTENCY_TTL_MS = 86_400_000
|
|
18
|
+
|
|
19
|
+
/** Canonical request header carrying the client-chosen idempotency key. */
|
|
20
|
+
export const DEFAULT_IDEMPOTENCY_HEADER = "idempotency-key"
|
|
21
|
+
|
|
22
|
+
/** Header stamped on a replayed response so clients/proxies can tell a replay from a fresh run. */
|
|
23
|
+
export const IDEMPOTENT_REPLAY_HEADER = "x-nifra-idempotent-replay"
|
|
24
|
+
|
|
25
|
+
const KEY_MAX_LENGTH = 255
|
|
26
|
+
|
|
27
|
+
// A replay is a new HTTP exchange, not a byte-for-byte resurrection of connection state. Session
|
|
28
|
+
// setters and hop-by-hop headers belong only to the winning response. Filter on both write and read:
|
|
29
|
+
// durable stores may still contain records created by an older Nifra version.
|
|
30
|
+
const REPLAY_UNSAFE_RESPONSE_HEADERS: ReadonlySet<string> = new Set([
|
|
31
|
+
"authentication-info",
|
|
32
|
+
"connection",
|
|
33
|
+
"keep-alive",
|
|
34
|
+
"proxy-authenticate",
|
|
35
|
+
"proxy-authentication-info",
|
|
36
|
+
"proxy-authorization",
|
|
37
|
+
"set-cookie",
|
|
38
|
+
"te",
|
|
39
|
+
"trailer",
|
|
40
|
+
"transfer-encoding",
|
|
41
|
+
"upgrade",
|
|
42
|
+
])
|
|
43
|
+
|
|
44
|
+
function replaySafeHeaders(
|
|
45
|
+
headers: Iterable<readonly [string, string]>,
|
|
46
|
+
): readonly (readonly [string, string])[] {
|
|
47
|
+
const safe: Array<readonly [string, string]> = []
|
|
48
|
+
for (const [name, value] of headers) {
|
|
49
|
+
if (!REPLAY_UNSAFE_RESPONSE_HEADERS.has(name.toLowerCase())) safe.push([name, value])
|
|
50
|
+
}
|
|
51
|
+
return safe
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** A serialized response held by a store. `body` is base64 so binary payloads round-trip intact. */
|
|
55
|
+
export interface StoredResponse {
|
|
56
|
+
readonly status: number
|
|
57
|
+
readonly headers: readonly (readonly [string, string])[]
|
|
58
|
+
/** Base64-encoded response body; `""` when the response had no body. */
|
|
59
|
+
readonly body: string
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Outcome of reserving a key. `new` → the caller runs the handler and later calls {@link
|
|
64
|
+
* IdempotencyStore.complete}. `replay` → return the stored response, handler never runs. `mismatch`
|
|
65
|
+
* → same key, different request fingerprint (client bug) → 409. `in-flight` → the key is reserved but
|
|
66
|
+
* not yet completed (a concurrent duplicate) → 409 + Retry-After.
|
|
67
|
+
*/
|
|
68
|
+
export type IdempotencyBeginResult =
|
|
69
|
+
| { readonly state: "new"; readonly reservation: string }
|
|
70
|
+
| { readonly state: "replay"; readonly response: StoredResponse }
|
|
71
|
+
| { readonly state: "mismatch" }
|
|
72
|
+
| { readonly state: "in-flight" }
|
|
73
|
+
| { readonly state: "capacity" }
|
|
74
|
+
|
|
75
|
+
/** Namespaces isolate the same client key across tenants/subjects without putting identity in a header. */
|
|
76
|
+
export interface IdempotencyEntryKey {
|
|
77
|
+
readonly namespace: string
|
|
78
|
+
readonly key: string
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface IdempotencyBeginInput extends IdempotencyEntryKey {
|
|
82
|
+
readonly fingerprint: string
|
|
83
|
+
readonly ttlMs: number
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface IdempotencyCompletionInput extends IdempotencyEntryKey {
|
|
87
|
+
/** Opaque ownership token returned by `begin(state:"new")`. */
|
|
88
|
+
readonly reservation: string
|
|
89
|
+
readonly response: StoredResponse
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface IdempotencyAbandonInput extends IdempotencyEntryKey {
|
|
93
|
+
/** Opaque ownership token returned by `begin(state:"new")`. */
|
|
94
|
+
readonly reservation: string
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Storage seam for idempotent responses. `begin` MUST be atomic: for one key, exactly one concurrent
|
|
99
|
+
* caller sees `new`; the rest see `in-flight` (or `replay` once completed). The in-memory store gets
|
|
100
|
+
* this free from the single-threaded event loop; a durable store uses an atomic insert.
|
|
101
|
+
*/
|
|
102
|
+
export interface IdempotencyStore {
|
|
103
|
+
/**
|
|
104
|
+
* An honest durability marker. Omit/`memory` for process-local stores; a route declaring
|
|
105
|
+
* `scope: "durable"` rejects anything other than `durable` at registration.
|
|
106
|
+
*/
|
|
107
|
+
readonly durability?: "memory" | "durable"
|
|
108
|
+
/** Reserve one namespaced key. Exactly one concurrent caller may receive `new`. */
|
|
109
|
+
begin(input: IdempotencyBeginInput): IdempotencyBeginResult | Promise<IdempotencyBeginResult>
|
|
110
|
+
/**
|
|
111
|
+
* Persist the final response only when `reservation` still owns the key. Returns false for a stale
|
|
112
|
+
* completion (for example after expiry + re-reservation), which prevents an older request from
|
|
113
|
+
* overwriting a newer result.
|
|
114
|
+
*/
|
|
115
|
+
complete(input: IdempotencyCompletionInput): boolean | Promise<boolean>
|
|
116
|
+
/** Release only the pending reservation owned by `reservation`. */
|
|
117
|
+
abandon(input: IdempotencyAbandonInput): boolean | Promise<boolean>
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** A key must be a non-empty, bounded, control-char-free token. Fail closed on anything else. */
|
|
121
|
+
export function validIdempotencyKey(key: string): boolean {
|
|
122
|
+
if (typeof key !== "string" || key.length === 0 || key.length > KEY_MAX_LENGTH) return false
|
|
123
|
+
for (let i = 0; i < key.length; i++) {
|
|
124
|
+
const code = key.charCodeAt(i)
|
|
125
|
+
if (code < 33 || code > 126) return false
|
|
126
|
+
}
|
|
127
|
+
return true
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Namespace values are server-resolved, bounded opaque tokens (normally a tenant/subject hash). */
|
|
131
|
+
export function validIdempotencyNamespace(namespace: string): boolean {
|
|
132
|
+
return validIdempotencyKey(namespace)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const encoder = new TextEncoder()
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* SHA-256 fingerprint binding a key to one request: method, path (+ query), and the raw body bytes.
|
|
139
|
+
* A collision-resistant hash matters — a weak hash would let a crafted body replay another's response.
|
|
140
|
+
*/
|
|
141
|
+
export async function computeIdempotencyFingerprint(
|
|
142
|
+
method: string,
|
|
143
|
+
path: string,
|
|
144
|
+
body: Uint8Array,
|
|
145
|
+
contentType = "",
|
|
146
|
+
): Promise<string> {
|
|
147
|
+
const mediaType = contentType.split(";", 1)[0]?.trim().toLowerCase() ?? ""
|
|
148
|
+
const head = encoder.encode(`${method.toUpperCase()}\n${path}\n${mediaType}\n`)
|
|
149
|
+
const material = new Uint8Array(head.length + body.length)
|
|
150
|
+
material.set(head, 0)
|
|
151
|
+
material.set(body, head.length)
|
|
152
|
+
const digest = await crypto.subtle.digest("SHA-256", material)
|
|
153
|
+
const bytes = new Uint8Array(digest)
|
|
154
|
+
let hex = ""
|
|
155
|
+
for (let i = 0; i < bytes.length; i++) hex += (bytes[i] as number).toString(16).padStart(2, "0")
|
|
156
|
+
return hex
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function canonicalJson(value: unknown): string {
|
|
160
|
+
if (value === null || typeof value === "boolean" || typeof value === "string") {
|
|
161
|
+
return JSON.stringify(value)
|
|
162
|
+
}
|
|
163
|
+
if (typeof value === "number") {
|
|
164
|
+
if (!Number.isFinite(value)) throw new TypeError("non-finite JSON number")
|
|
165
|
+
return JSON.stringify(value)
|
|
166
|
+
}
|
|
167
|
+
if (Array.isArray(value)) return `[${value.map((item) => canonicalJson(item)).join(",")}]`
|
|
168
|
+
if (typeof value === "object") {
|
|
169
|
+
const record = value as Record<string, unknown>
|
|
170
|
+
const entries = Object.keys(record)
|
|
171
|
+
.sort()
|
|
172
|
+
.map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`)
|
|
173
|
+
return `{${entries.join(",")}}`
|
|
174
|
+
}
|
|
175
|
+
throw new TypeError("unsupported JSON value")
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Canonicalize JSON bodies so whitespace/property-order retries bind to the same semantic request. */
|
|
179
|
+
export function canonicalizeIdempotencyBody(
|
|
180
|
+
body: Uint8Array,
|
|
181
|
+
contentType: string | null,
|
|
182
|
+
): Uint8Array {
|
|
183
|
+
const mediaType = contentType?.split(";", 1)[0]?.trim().toLowerCase() ?? ""
|
|
184
|
+
if (mediaType !== "application/json" && !mediaType.endsWith("+json")) return body
|
|
185
|
+
try {
|
|
186
|
+
return encoder.encode(canonicalJson(JSON.parse(new TextDecoder().decode(body))))
|
|
187
|
+
} catch {
|
|
188
|
+
// Invalid JSON is rejected by the validation/body lane. Preserve its raw bytes for a stable
|
|
189
|
+
// fingerprint until that rejection releases the reservation.
|
|
190
|
+
return body
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const BASE64_CHUNK = 0x8000
|
|
195
|
+
|
|
196
|
+
function bytesToBase64(bytes: Uint8Array): string {
|
|
197
|
+
let binary = ""
|
|
198
|
+
for (let i = 0; i < bytes.length; i += BASE64_CHUNK) {
|
|
199
|
+
binary += String.fromCharCode(...bytes.subarray(i, i + BASE64_CHUNK))
|
|
200
|
+
}
|
|
201
|
+
return btoa(binary)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function base64ToBytes(value: string): Uint8Array {
|
|
205
|
+
const binary = atob(value)
|
|
206
|
+
const bytes = new Uint8Array(binary.length)
|
|
207
|
+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)
|
|
208
|
+
return bytes
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function base64DecodedLength(value: string): number {
|
|
212
|
+
if (value === "") return 0
|
|
213
|
+
if (value.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/.test(value)) {
|
|
214
|
+
throw new TypeError("idempotency: corrupt stored response body")
|
|
215
|
+
}
|
|
216
|
+
const padding = value.endsWith("==") ? 2 : value.endsWith("=") ? 1 : 0
|
|
217
|
+
return (value.length / 4) * 3 - padding
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export class IdempotencyResponseTooLargeError extends Error {
|
|
221
|
+
constructor(public readonly maxBytes: number) {
|
|
222
|
+
super(`idempotency response exceeds the ${maxBytes}-byte storage bound`)
|
|
223
|
+
this.name = "IdempotencyResponseTooLargeError"
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async function boundedResponseBytes(response: Response, maxBytes: number): Promise<Uint8Array> {
|
|
228
|
+
const contentLength = response.headers.get("content-length")
|
|
229
|
+
if (contentLength !== null && /^\d+$/.test(contentLength) && Number(contentLength) > maxBytes) {
|
|
230
|
+
throw new IdempotencyResponseTooLargeError(maxBytes)
|
|
231
|
+
}
|
|
232
|
+
const body = response.clone().body
|
|
233
|
+
if (body === null) return new Uint8Array()
|
|
234
|
+
const reader = body.getReader()
|
|
235
|
+
const chunks: Uint8Array[] = []
|
|
236
|
+
let total = 0
|
|
237
|
+
try {
|
|
238
|
+
for (;;) {
|
|
239
|
+
const { done, value } = await reader.read()
|
|
240
|
+
if (done) break
|
|
241
|
+
total += value.byteLength
|
|
242
|
+
if (total > maxBytes) {
|
|
243
|
+
await reader.cancel()
|
|
244
|
+
throw new IdempotencyResponseTooLargeError(maxBytes)
|
|
245
|
+
}
|
|
246
|
+
chunks.push(value)
|
|
247
|
+
}
|
|
248
|
+
} finally {
|
|
249
|
+
reader.releaseLock()
|
|
250
|
+
}
|
|
251
|
+
const bytes = new Uint8Array(total)
|
|
252
|
+
let offset = 0
|
|
253
|
+
for (const chunk of chunks) {
|
|
254
|
+
bytes.set(chunk, offset)
|
|
255
|
+
offset += chunk.byteLength
|
|
256
|
+
}
|
|
257
|
+
return bytes
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Buffer a response into a storable form. Clones first so the live response body stays intact. */
|
|
261
|
+
export async function serializeResponse(
|
|
262
|
+
response: Response,
|
|
263
|
+
options: { readonly maxBytes?: number } = {},
|
|
264
|
+
): Promise<StoredResponse> {
|
|
265
|
+
const maxBytes = options.maxBytes
|
|
266
|
+
if (maxBytes !== undefined && (!Number.isInteger(maxBytes) || maxBytes < 0)) {
|
|
267
|
+
throw new RangeError("idempotency: max response bytes must be a non-negative integer")
|
|
268
|
+
}
|
|
269
|
+
const bytes =
|
|
270
|
+
maxBytes === undefined
|
|
271
|
+
? new Uint8Array(await response.clone().arrayBuffer())
|
|
272
|
+
: await boundedResponseBytes(response, maxBytes)
|
|
273
|
+
return {
|
|
274
|
+
status: response.status,
|
|
275
|
+
headers: replaySafeHeaders(response.headers),
|
|
276
|
+
body: bytes.length > 0 ? bytesToBase64(bytes) : "",
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** Rebuild a live response from storage, stamping the replay marker header. */
|
|
281
|
+
export function responseFromStored(
|
|
282
|
+
stored: StoredResponse,
|
|
283
|
+
options: { readonly maxBytes?: number } = {},
|
|
284
|
+
): Response {
|
|
285
|
+
if (!Number.isInteger(stored.status) || stored.status < 200 || stored.status > 599) {
|
|
286
|
+
throw new TypeError("idempotency: corrupt stored response status")
|
|
287
|
+
}
|
|
288
|
+
const decodedLength = base64DecodedLength(stored.body)
|
|
289
|
+
if (options.maxBytes !== undefined) {
|
|
290
|
+
if (!Number.isSafeInteger(options.maxBytes) || options.maxBytes < 0) {
|
|
291
|
+
throw new RangeError("idempotency: max response bytes must be a non-negative safe integer")
|
|
292
|
+
}
|
|
293
|
+
if (decodedLength > options.maxBytes) {
|
|
294
|
+
throw new IdempotencyResponseTooLargeError(options.maxBytes)
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
const headers = new Headers(replaySafeHeaders(stored.headers) as [string, string][])
|
|
298
|
+
headers.set(IDEMPOTENT_REPLAY_HEADER, "1")
|
|
299
|
+
// Reference the Response body type structurally: `BodyInit` isn't a named type under every lib config,
|
|
300
|
+
// but a fresh `Uint8Array` is a valid body — the cast bridges the `ArrayBufferLike` generic variance.
|
|
301
|
+
const body = (stored.body === "" ? null : base64ToBytes(stored.body)) as ConstructorParameters<
|
|
302
|
+
typeof Response
|
|
303
|
+
>[0]
|
|
304
|
+
return new Response(body, { status: stored.status, headers })
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
interface MemoryEntry {
|
|
308
|
+
readonly fingerprint: string
|
|
309
|
+
readonly reservation: string
|
|
310
|
+
readonly ttlMs: number
|
|
311
|
+
expiresAt: number
|
|
312
|
+
response: StoredResponse | undefined
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
export interface MemoryIdempotencyStoreOptions {
|
|
316
|
+
/** Injectable clock (epoch ms) for deterministic TTL tests. Default `Date.now`. */
|
|
317
|
+
readonly now?: () => number
|
|
318
|
+
/** Hard memory bound. At capacity new keys fail closed; completed/pending entries are never evicted early. */
|
|
319
|
+
readonly maxEntries?: number
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* In-process idempotency store. Reservation is atomic by construction — `begin` never awaits, so the
|
|
324
|
+
* single-threaded event loop serializes concurrent callers for one key. Expired entries are treated
|
|
325
|
+
* as absent (lazy eviction on access); a periodic {@link MemoryIdempotencyStore.sweep} bounds memory.
|
|
326
|
+
*/
|
|
327
|
+
export class MemoryIdempotencyStore implements IdempotencyStore {
|
|
328
|
+
readonly durability = "memory" as const
|
|
329
|
+
private readonly entries = new Map<string, MemoryEntry>()
|
|
330
|
+
private readonly now: () => number
|
|
331
|
+
private readonly maxEntries: number
|
|
332
|
+
|
|
333
|
+
constructor(options: MemoryIdempotencyStoreOptions = {}) {
|
|
334
|
+
this.now = options.now ?? Date.now
|
|
335
|
+
this.maxEntries = options.maxEntries ?? 10_000
|
|
336
|
+
if (!Number.isInteger(this.maxEntries) || this.maxEntries < 1) {
|
|
337
|
+
throw new RangeError("idempotency: maxEntries must be a positive integer")
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
begin(input: IdempotencyBeginInput): IdempotencyBeginResult {
|
|
342
|
+
const storageKey = this.storageKey(input)
|
|
343
|
+
const existing = this.entries.get(storageKey)
|
|
344
|
+
if (existing !== undefined && existing.expiresAt > this.now()) {
|
|
345
|
+
if (existing.fingerprint !== input.fingerprint) return { state: "mismatch" }
|
|
346
|
+
if (existing.response !== undefined) return { state: "replay", response: existing.response }
|
|
347
|
+
return { state: "in-flight" }
|
|
348
|
+
}
|
|
349
|
+
if (existing !== undefined) this.entries.delete(storageKey)
|
|
350
|
+
if (this.entries.size >= this.maxEntries) {
|
|
351
|
+
this.sweep()
|
|
352
|
+
if (this.entries.size >= this.maxEntries) return { state: "capacity" }
|
|
353
|
+
}
|
|
354
|
+
const reservation = crypto.randomUUID()
|
|
355
|
+
this.entries.set(storageKey, {
|
|
356
|
+
fingerprint: input.fingerprint,
|
|
357
|
+
reservation,
|
|
358
|
+
ttlMs: input.ttlMs,
|
|
359
|
+
expiresAt: this.now() + input.ttlMs,
|
|
360
|
+
response: undefined,
|
|
361
|
+
})
|
|
362
|
+
return { state: "new", reservation }
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
complete(input: IdempotencyCompletionInput): boolean {
|
|
366
|
+
const key = this.storageKey(input)
|
|
367
|
+
const entry = this.entries.get(key)
|
|
368
|
+
if (entry === undefined || entry.reservation !== input.reservation) return false
|
|
369
|
+
if (entry.expiresAt <= this.now()) {
|
|
370
|
+
this.entries.delete(key)
|
|
371
|
+
return false
|
|
372
|
+
}
|
|
373
|
+
entry.response = input.response
|
|
374
|
+
entry.expiresAt = this.now() + entry.ttlMs
|
|
375
|
+
return true
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
abandon(input: IdempotencyAbandonInput): boolean {
|
|
379
|
+
const key = this.storageKey(input)
|
|
380
|
+
const entry = this.entries.get(key)
|
|
381
|
+
if (entry !== undefined && entry.expiresAt <= this.now()) {
|
|
382
|
+
this.entries.delete(key)
|
|
383
|
+
return false
|
|
384
|
+
}
|
|
385
|
+
// Only drop a still-pending reservation; never evict a completed (replayable) response.
|
|
386
|
+
if (
|
|
387
|
+
entry !== undefined &&
|
|
388
|
+
entry.response === undefined &&
|
|
389
|
+
entry.reservation === input.reservation
|
|
390
|
+
) {
|
|
391
|
+
this.entries.delete(key)
|
|
392
|
+
return true
|
|
393
|
+
}
|
|
394
|
+
return false
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** Evict expired entries. Callers may run this on an interval; access-time eviction covers the rest. */
|
|
398
|
+
sweep(): void {
|
|
399
|
+
const now = this.now()
|
|
400
|
+
for (const [key, entry] of this.entries) {
|
|
401
|
+
if (entry.expiresAt <= now) this.entries.delete(key)
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** Live entry count (post-sweep semantics are the caller's; this is a raw size for tests/metrics). */
|
|
406
|
+
get size(): number {
|
|
407
|
+
return this.entries.size
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
private storageKey(input: IdempotencyEntryKey): string {
|
|
411
|
+
return `${input.namespace.length}:${input.namespace}${input.key}`
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/** Convenience factory mirroring the other core primitives' `create*` style. */
|
|
416
|
+
export function createMemoryIdempotencyStore(
|
|
417
|
+
options?: MemoryIdempotencyStoreOptions,
|
|
418
|
+
): MemoryIdempotencyStore {
|
|
419
|
+
return new MemoryIdempotencyStore(options)
|
|
420
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -12,14 +12,21 @@
|
|
|
12
12
|
* (it shipped at "0.0.0" through 1.0.0 before those guards existed). Kept narrow (`as const`) so
|
|
13
13
|
* consumers can pin it at the type level — see `test/version.test-d.ts`.
|
|
14
14
|
*/
|
|
15
|
-
export const VERSION = "1.
|
|
15
|
+
export const VERSION = "1.11.0" as const
|
|
16
16
|
|
|
17
17
|
export type Version = typeof VERSION
|
|
18
18
|
|
|
19
19
|
export * from "./assurance.ts"
|
|
20
|
+
export * from "./capabilities.ts"
|
|
21
|
+
export * from "./classification.ts"
|
|
20
22
|
export * from "./diff.ts"
|
|
21
23
|
export { FrameworkError, RouteConfigError, type RouteConfigErrorCode } from "./errors.ts"
|
|
24
|
+
export * from "./idempotency.ts"
|
|
22
25
|
export { FRAMEWORK_NAME, type FrameworkName } from "./internal/brand.ts"
|
|
26
|
+
export * from "./invariants.ts"
|
|
27
|
+
export * from "./ledger.ts"
|
|
28
|
+
export * from "./manifest.ts"
|
|
29
|
+
export * from "./mount.ts"
|
|
23
30
|
export * from "./reflection.ts"
|
|
24
31
|
export { METHODS, type Method, Router, type RouterMatch } from "./router/router.ts"
|
|
25
32
|
export type {
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
export const CAPABILITY_GUARD = Symbol("nifra.capability.guard")
|
|
2
|
+
const CAPABILITY_ID = /^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*$/
|
|
3
|
+
|
|
4
|
+
export function validCapabilityId(value: string): boolean {
|
|
5
|
+
return CAPABILITY_ID.test(value)
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface CapabilityUseEvent {
|
|
9
|
+
readonly capability: string
|
|
10
|
+
readonly method: string
|
|
11
|
+
readonly path: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface CapabilityGuard {
|
|
15
|
+
readonly allowed: readonly string[]
|
|
16
|
+
readonly method: string
|
|
17
|
+
readonly path: string
|
|
18
|
+
readonly onUse?: (event: CapabilityUseEvent) => void
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function normalizeRouteCapabilities(
|
|
22
|
+
values: readonly string[] | undefined,
|
|
23
|
+
): readonly string[] {
|
|
24
|
+
if (values === undefined) return Object.freeze([])
|
|
25
|
+
if (!Array.isArray(values)) throw new TypeError("route capabilities must be an array")
|
|
26
|
+
const out: string[] = []
|
|
27
|
+
const seen = new Set<string>()
|
|
28
|
+
for (const value of values) {
|
|
29
|
+
if (typeof value !== "string" || !validCapabilityId(value)) {
|
|
30
|
+
throw new Error(
|
|
31
|
+
`route capabilities: invalid capability id ${JSON.stringify(value)} (use lowercase dot/dash segments)`,
|
|
32
|
+
)
|
|
33
|
+
}
|
|
34
|
+
if (!seen.has(value)) {
|
|
35
|
+
seen.add(value)
|
|
36
|
+
out.push(value)
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return Object.freeze(out)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function createCapabilityGuard(
|
|
43
|
+
allowed: readonly string[],
|
|
44
|
+
method: string,
|
|
45
|
+
path: string,
|
|
46
|
+
onUse: ((event: CapabilityUseEvent) => void) | undefined,
|
|
47
|
+
): CapabilityGuard {
|
|
48
|
+
return Object.freeze({ allowed, method, path, ...(onUse !== undefined ? { onUse } : {}) })
|
|
49
|
+
}
|
|
@@ -26,6 +26,7 @@ export const NIFRA_ASSURANCE_IDS = Object.freeze({
|
|
|
26
26
|
AUTHENTICATED: "nifra.authenticated",
|
|
27
27
|
BODY_BOUNDED: "nifra.body-bounded",
|
|
28
28
|
CSRF: "nifra.csrf",
|
|
29
|
+
DURABLE_COMMAND: "nifra.durable-command",
|
|
29
30
|
IDEMPOTENCY_KEY: "nifra.idempotency-key",
|
|
30
31
|
IP_RESTRICTED: "nifra.ip-restricted",
|
|
31
32
|
RATE_LIMITED: "nifra.rate-limited",
|