@nifrajs/core 1.10.0 → 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 +13 -0
- package/dist/assurance.d.ts.map +1 -1
- package/dist/assurance.js +20 -0
- package/dist/assurance.js.map +1 -1
- package/dist/capabilities.d.ts +30 -1
- package/dist/capabilities.d.ts.map +1 -1
- package/dist/capabilities.js +32 -6
- package/dist/capabilities.js.map +1 -1
- package/dist/classification.d.ts +20 -0
- package/dist/classification.d.ts.map +1 -1
- package/dist/classification.js +120 -0
- package/dist/classification.js.map +1 -1
- 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 +59 -12
- package/dist/idempotency.d.ts.map +1 -1
- package/dist/idempotency.js +191 -20
- package/dist/idempotency.js.map +1 -1
- package/dist/index.d.ts +5 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -1
- package/dist/index.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 +3 -3
- package/dist/reflection.d.ts.map +1 -1
- package/dist/reflection.js +5 -5
- package/dist/reflection.js.map +1 -1
- package/dist/server/context.d.ts +17 -1
- package/dist/server/context.d.ts.map +1 -1
- package/dist/server/server.d.ts +23 -2
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +179 -26
- package/dist/server/server.js.map +1 -1
- package/package.json +26 -1
- package/src/assurance.ts +33 -0
- package/src/capabilities.ts +60 -4
- package/src/classification.ts +145 -0
- package/src/errors.ts +1 -0
- package/src/idempotency.ts +245 -30
- package/src/index.ts +5 -1
- 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 +11 -7
- package/src/server/context.ts +20 -1
- package/src/server/server.ts +254 -30
package/src/server/server.ts
CHANGED
|
@@ -8,14 +8,17 @@ import {
|
|
|
8
8
|
} from "../budget.ts"
|
|
9
9
|
import { FrameworkError, RouteConfigError } from "../errors.ts"
|
|
10
10
|
import {
|
|
11
|
+
canonicalizeIdempotencyBody,
|
|
11
12
|
computeIdempotencyFingerprint,
|
|
12
13
|
DEFAULT_IDEMPOTENCY_HEADER,
|
|
13
14
|
DEFAULT_IDEMPOTENCY_TTL_MS,
|
|
15
|
+
IdempotencyResponseTooLargeError,
|
|
14
16
|
type IdempotencyStore,
|
|
15
17
|
MemoryIdempotencyStore,
|
|
16
18
|
responseFromStored,
|
|
17
19
|
serializeResponse,
|
|
18
20
|
validIdempotencyKey,
|
|
21
|
+
validIdempotencyNamespace,
|
|
19
22
|
} from "../idempotency.ts"
|
|
20
23
|
import {
|
|
21
24
|
CAPABILITY_GUARD,
|
|
@@ -30,6 +33,13 @@ import {
|
|
|
30
33
|
assuranceEvidenceFor,
|
|
31
34
|
NIFRA_ASSURANCE_IDS,
|
|
32
35
|
} from "../internal/route-assurance.ts"
|
|
36
|
+
import {
|
|
37
|
+
attachEffectLedger,
|
|
38
|
+
createRequestLedger,
|
|
39
|
+
DEFAULT_MAX_ENTRIES,
|
|
40
|
+
type EffectLedgerOptions,
|
|
41
|
+
type RequestLedger,
|
|
42
|
+
} from "../ledger.ts"
|
|
33
43
|
import { EMPTY_PARAMS, type Method, Router } from "../router/router.ts"
|
|
34
44
|
import type {
|
|
35
45
|
InferOutput,
|
|
@@ -164,7 +174,7 @@ type RawAfterHandle = (result: unknown, ctx: RawContext) => MaybePromise<unknown
|
|
|
164
174
|
type RawErrorHandler = (error: unknown, ctx: RawContext) => MaybePromise<unknown>
|
|
165
175
|
type RawAround = <T>(ctx: RawContext, next: () => MaybePromise<T>) => MaybePromise<T>
|
|
166
176
|
export type OnRequestResult = Response | Request | undefined
|
|
167
|
-
type RawOnRequest = (req: Request) => MaybePromise<OnRequestResult>
|
|
177
|
+
type RawOnRequest = (req: Request, platform?: Platform) => MaybePromise<OnRequestResult>
|
|
168
178
|
type RawOnResponse = (response: Response, req: Request) => MaybePromise<Response>
|
|
169
179
|
type RawOnResponseFinalized = (outcome: ResponseFinalization, req: Request) => MaybePromise<void>
|
|
170
180
|
|
|
@@ -173,6 +183,20 @@ interface ResolvedIdempotency {
|
|
|
173
183
|
readonly store: IdempotencyStore
|
|
174
184
|
readonly ttlMs: number
|
|
175
185
|
readonly headerName: string
|
|
186
|
+
readonly namespace: NonNullable<RouteSchema["idempotency"]>["namespace"]
|
|
187
|
+
readonly maxResponseBytes: number
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Registration-resolved effect-ledger wiring for one capability-declaring route. */
|
|
191
|
+
interface ResolvedEffectLedger {
|
|
192
|
+
readonly sink: EffectLedgerOptions["sink"]
|
|
193
|
+
readonly maxEntries: number
|
|
194
|
+
readonly chain: boolean
|
|
195
|
+
/** The registered route pattern — what the sealed ledger names (never the concrete URL). */
|
|
196
|
+
readonly method: string
|
|
197
|
+
readonly path: string
|
|
198
|
+
/** The route's declared capability tokens, surfaced on the sealed ledger. */
|
|
199
|
+
readonly declared: readonly string[]
|
|
176
200
|
}
|
|
177
201
|
|
|
178
202
|
interface RouteEntry {
|
|
@@ -180,6 +204,8 @@ interface RouteEntry {
|
|
|
180
204
|
readonly schema: RouteSchema | undefined
|
|
181
205
|
/** Resolved idempotency config; `undefined` = off (the dedupe lane is never entered). */
|
|
182
206
|
readonly idempotent: ResolvedIdempotency | undefined
|
|
207
|
+
/** Resolved effect-ledger wiring; `undefined` = off (no per-request ledger, no settle step). */
|
|
208
|
+
readonly ledgered: ResolvedEffectLedger | undefined
|
|
183
209
|
/** Per-request context extensions captured at registration (order-scoped). */
|
|
184
210
|
readonly derives: ReadonlyArray<RawDerive>
|
|
185
211
|
/** Static context extensions captured at registration. */
|
|
@@ -343,6 +369,16 @@ export interface ServerOptions {
|
|
|
343
369
|
* parameters, body, tenant, or values. Routes without capability declarations keep the old hot path.
|
|
344
370
|
*/
|
|
345
371
|
readonly onCapabilityUse?: (event: CapabilityUseEvent) => void
|
|
372
|
+
/**
|
|
373
|
+
* Enable the per-request effect ledger. Each route that declares `schema.capabilities` gets a
|
|
374
|
+
* bounded, token-only ledger; `useCapability(c, id, …)` appends one entry per effect, and the sink
|
|
375
|
+
* receives the sealed ledger when the response settles (only when it recorded entries). Token-only
|
|
376
|
+
* by construction — capability ids, phases, counters, digests; never payloads, values, or the
|
|
377
|
+
* concrete URL (the route *pattern* is recorded). Enabling it moves capability-declaring routes off
|
|
378
|
+
* the fused fast path (they need a per-request context + a settle step); all other routes are
|
|
379
|
+
* unaffected, and when unset the request path pays nothing.
|
|
380
|
+
*/
|
|
381
|
+
readonly effectLedger?: EffectLedgerOptions
|
|
346
382
|
/**
|
|
347
383
|
* Default store backing routes that declare `schema.idempotency` without their own `store`. Defaults
|
|
348
384
|
* to a shared in-process {@link MemoryIdempotencyStore} (created lazily on first idempotent route).
|
|
@@ -450,7 +486,7 @@ export interface RunningServer {
|
|
|
450
486
|
*/
|
|
451
487
|
export interface Middleware {
|
|
452
488
|
readonly name?: string
|
|
453
|
-
readonly onRequest?: (req: Request) => MaybePromise<OnRequestResult>
|
|
489
|
+
readonly onRequest?: (req: Request, platform?: Platform) => MaybePromise<OnRequestResult>
|
|
454
490
|
readonly around?: <T>(context: Context, next: () => MaybePromise<T>) => MaybePromise<T>
|
|
455
491
|
readonly beforeHandle?: (context: Context) => MaybePromise<unknown>
|
|
456
492
|
readonly afterHandle?: (result: unknown, context: Context) => MaybePromise<unknown>
|
|
@@ -1272,6 +1308,10 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1272
1308
|
/** Capacity-admission gate; `undefined` = off (the request path pays nothing). */
|
|
1273
1309
|
private readonly capacityGate: AdmissionController | undefined
|
|
1274
1310
|
private readonly onCapabilityUse: ((event: CapabilityUseEvent) => void) | undefined
|
|
1311
|
+
/** Effect-ledger defaults pinned at construction; per-route `ledgered` copies add route identity. */
|
|
1312
|
+
private readonly effectLedger:
|
|
1313
|
+
| Omit<ResolvedEffectLedger, "method" | "path" | "declared">
|
|
1314
|
+
| undefined
|
|
1275
1315
|
/** App-wide idempotency store (explicit option, else a lazily-created in-process default). */
|
|
1276
1316
|
private readonly configuredIdempotencyStore: IdempotencyStore | undefined
|
|
1277
1317
|
private lazyIdempotencyStore: IdempotencyStore | undefined
|
|
@@ -1336,6 +1376,23 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1336
1376
|
this.gracefulSignals = options.gracefulSignals ?? false
|
|
1337
1377
|
this.capacityGate = options.admission
|
|
1338
1378
|
this.onCapabilityUse = options.onCapabilityUse
|
|
1379
|
+
if (options.effectLedger !== undefined) {
|
|
1380
|
+
const ledger = options.effectLedger
|
|
1381
|
+
if (typeof ledger.sink !== "function") {
|
|
1382
|
+
throw new TypeError("effectLedger.sink must be a function")
|
|
1383
|
+
}
|
|
1384
|
+
const maxEntries = ledger.maxEntries ?? DEFAULT_MAX_ENTRIES
|
|
1385
|
+
if (!Number.isSafeInteger(maxEntries) || maxEntries < 1) {
|
|
1386
|
+
throw new RangeError("effectLedger.maxEntries must be a positive safe integer")
|
|
1387
|
+
}
|
|
1388
|
+
this.effectLedger = Object.freeze({
|
|
1389
|
+
sink: ledger.sink,
|
|
1390
|
+
maxEntries,
|
|
1391
|
+
chain: ledger.chain ?? false,
|
|
1392
|
+
})
|
|
1393
|
+
} else {
|
|
1394
|
+
this.effectLedger = undefined
|
|
1395
|
+
}
|
|
1339
1396
|
this.configuredIdempotencyStore = options.idempotencyStore
|
|
1340
1397
|
this.lazyIdempotencyStore = undefined
|
|
1341
1398
|
this.logger = options.logger ?? jsonLogger()
|
|
@@ -1375,8 +1432,10 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1375
1432
|
* Run before routing on the raw request. Return a `Response` to short-circuit, or a replacement
|
|
1376
1433
|
* `Request` to continue routing with a rewritten method/URL/headers. Global.
|
|
1377
1434
|
*/
|
|
1378
|
-
onRequest(
|
|
1379
|
-
|
|
1435
|
+
onRequest(
|
|
1436
|
+
fn: (req: Request, platform?: Platform<EnvOf<Ctx>>) => MaybePromise<OnRequestResult>,
|
|
1437
|
+
): this {
|
|
1438
|
+
this.onRequestHooks.push(fn as RawOnRequest)
|
|
1380
1439
|
return this
|
|
1381
1440
|
}
|
|
1382
1441
|
|
|
@@ -1800,6 +1859,11 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1800
1859
|
`route handler assurance must use plugin scope (received ${invalidHandlerScope.scope})`,
|
|
1801
1860
|
)
|
|
1802
1861
|
}
|
|
1862
|
+
const authenticated = assuranceEvidenceFor(
|
|
1863
|
+
[...this.activeAssurance, ...handlerAssurance, ...this.globalAssurance],
|
|
1864
|
+
method,
|
|
1865
|
+
path,
|
|
1866
|
+
).some((evidence) => evidence.id === NIFRA_ASSURANCE_IDS.AUTHENTICATED)
|
|
1803
1867
|
const routeDecorations: Record<PropertyKey, unknown> = { ...this.decorations }
|
|
1804
1868
|
if (capabilities.length > 0) {
|
|
1805
1869
|
routeDecorations[CAPABILITY_GUARD] = createCapabilityGuard(
|
|
@@ -1813,11 +1877,19 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1813
1877
|
// An idempotency route runs a dedupe lane that must buffer the body and capture the response, so it
|
|
1814
1878
|
// never takes the fused/native fast path — force it onto the portable matched lane (which routes
|
|
1815
1879
|
// through `fetchMatched`, where the dedupe wrapper lives).
|
|
1816
|
-
const idempotent = this.resolveIdempotency(schema)
|
|
1880
|
+
const idempotent = this.resolveIdempotency(schema, authenticated)
|
|
1881
|
+
// A ledgered route (capabilities declared + `server({ effectLedger })`) needs a per-request
|
|
1882
|
+
// context to carry the ledger and a settle step to seal + sink it, so it too leaves the
|
|
1883
|
+
// fused/contextless fast path. Resolved per route, at registration — like the capability guard.
|
|
1884
|
+
const ledgered: ResolvedEffectLedger | undefined =
|
|
1885
|
+
capabilities.length > 0 && this.effectLedger !== undefined
|
|
1886
|
+
? Object.freeze({ ...this.effectLedger, method, path, declared: capabilities })
|
|
1887
|
+
: undefined
|
|
1817
1888
|
const bare =
|
|
1818
1889
|
schema?.body === undefined &&
|
|
1819
1890
|
schema?.query === undefined &&
|
|
1820
1891
|
idempotent === undefined &&
|
|
1892
|
+
ledgered === undefined &&
|
|
1821
1893
|
this.derives.length === 0 &&
|
|
1822
1894
|
this.beforeHandleHooks.length === 0 &&
|
|
1823
1895
|
this.afterHandleHooks.length === 0 &&
|
|
@@ -1836,6 +1908,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1836
1908
|
handler: handler as unknown as InternalHandler,
|
|
1837
1909
|
schema,
|
|
1838
1910
|
idempotent,
|
|
1911
|
+
ledgered,
|
|
1839
1912
|
fusedWeb,
|
|
1840
1913
|
derives: [...this.derives],
|
|
1841
1914
|
decorations: routeDecorations,
|
|
@@ -1883,9 +1956,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1883
1956
|
}),
|
|
1884
1957
|
)
|
|
1885
1958
|
}
|
|
1886
|
-
// Declaring `schema.idempotency` is
|
|
1887
|
-
//
|
|
1888
|
-
// durable-command requirement.
|
|
1959
|
+
// Declaring `schema.idempotency` is evidence for request replay only. It deliberately never proves
|
|
1960
|
+
// durable command execution; that stronger evidence belongs to a command/outbox adapter.
|
|
1889
1961
|
if (schema?.idempotency !== undefined) {
|
|
1890
1962
|
routeAssurance.push(
|
|
1891
1963
|
Object.freeze({
|
|
@@ -1894,15 +1966,6 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1894
1966
|
scope: "plugin",
|
|
1895
1967
|
}),
|
|
1896
1968
|
)
|
|
1897
|
-
if (schema.idempotency.scope === "durable") {
|
|
1898
|
-
routeAssurance.push(
|
|
1899
|
-
Object.freeze({
|
|
1900
|
-
id: NIFRA_ASSURANCE_IDS.DURABLE_COMMAND,
|
|
1901
|
-
source: "route-schema",
|
|
1902
|
-
scope: "plugin",
|
|
1903
|
-
}),
|
|
1904
|
-
)
|
|
1905
|
-
}
|
|
1906
1969
|
}
|
|
1907
1970
|
this.rawEntries.push({
|
|
1908
1971
|
method,
|
|
@@ -1914,13 +1977,73 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1914
1977
|
}
|
|
1915
1978
|
|
|
1916
1979
|
/** Resolve a route's declared idempotency into a pinned store + defaults, or `undefined` when off. */
|
|
1917
|
-
private resolveIdempotency(
|
|
1980
|
+
private resolveIdempotency(
|
|
1981
|
+
schema: RouteSchema | undefined,
|
|
1982
|
+
authenticated: boolean,
|
|
1983
|
+
): ResolvedIdempotency | undefined {
|
|
1918
1984
|
const config = schema?.idempotency
|
|
1919
1985
|
if (config === undefined) return undefined
|
|
1986
|
+
if (schema?.sse !== undefined) {
|
|
1987
|
+
throw new RouteConfigError(
|
|
1988
|
+
"INVALID_IDEMPOTENCY",
|
|
1989
|
+
"streaming/SSE responses cannot be used on an idempotency route",
|
|
1990
|
+
)
|
|
1991
|
+
}
|
|
1992
|
+
const store = config.store ?? this.defaultIdempotencyStore()
|
|
1993
|
+
if (config.scope === "durable" && store.durability !== "durable") {
|
|
1994
|
+
throw new RouteConfigError(
|
|
1995
|
+
"INVALID_IDEMPOTENCY",
|
|
1996
|
+
'durable idempotency requires a durable store (store.durability must be "durable")',
|
|
1997
|
+
)
|
|
1998
|
+
}
|
|
1999
|
+
const ttlMs = config.ttlMs ?? DEFAULT_IDEMPOTENCY_TTL_MS
|
|
2000
|
+
if (!Number.isSafeInteger(ttlMs) || ttlMs <= 0) {
|
|
2001
|
+
throw new RouteConfigError(
|
|
2002
|
+
"INVALID_IDEMPOTENCY",
|
|
2003
|
+
"idempotency ttlMs must be a finite positive number",
|
|
2004
|
+
)
|
|
2005
|
+
}
|
|
2006
|
+
const headerName = (config.headerName ?? DEFAULT_IDEMPOTENCY_HEADER).toLowerCase()
|
|
2007
|
+
try {
|
|
2008
|
+
new Headers().set(headerName, "probe")
|
|
2009
|
+
} catch {
|
|
2010
|
+
throw new RouteConfigError(
|
|
2011
|
+
"INVALID_IDEMPOTENCY",
|
|
2012
|
+
`invalid idempotency header name ${JSON.stringify(headerName)}`,
|
|
2013
|
+
)
|
|
2014
|
+
}
|
|
2015
|
+
const namespace = config.namespace
|
|
2016
|
+
if (namespace === undefined) {
|
|
2017
|
+
throw new RouteConfigError(
|
|
2018
|
+
"INVALID_IDEMPOTENCY",
|
|
2019
|
+
"idempotency namespace is required; use a principal resolver or an explicit shared scope",
|
|
2020
|
+
)
|
|
2021
|
+
}
|
|
2022
|
+
if (typeof namespace === "string" && !validIdempotencyNamespace(namespace)) {
|
|
2023
|
+
throw new RouteConfigError(
|
|
2024
|
+
"INVALID_IDEMPOTENCY",
|
|
2025
|
+
`invalid idempotency namespace ${JSON.stringify(namespace)}`,
|
|
2026
|
+
)
|
|
2027
|
+
}
|
|
2028
|
+
if (authenticated && typeof namespace !== "function") {
|
|
2029
|
+
throw new RouteConfigError(
|
|
2030
|
+
"INVALID_IDEMPOTENCY",
|
|
2031
|
+
"authenticated idempotency routes require a principal namespace resolver",
|
|
2032
|
+
)
|
|
2033
|
+
}
|
|
2034
|
+
const maxResponseBytes = config.maxResponseBytes ?? this.maxBodyBytes
|
|
2035
|
+
if (!Number.isSafeInteger(maxResponseBytes) || maxResponseBytes < 1) {
|
|
2036
|
+
throw new RouteConfigError(
|
|
2037
|
+
"INVALID_IDEMPOTENCY",
|
|
2038
|
+
"idempotency maxResponseBytes must be a positive integer",
|
|
2039
|
+
)
|
|
2040
|
+
}
|
|
1920
2041
|
return Object.freeze({
|
|
1921
|
-
store
|
|
1922
|
-
ttlMs
|
|
1923
|
-
headerName
|
|
2042
|
+
store,
|
|
2043
|
+
ttlMs,
|
|
2044
|
+
headerName,
|
|
2045
|
+
namespace,
|
|
2046
|
+
maxResponseBytes,
|
|
1924
2047
|
})
|
|
1925
2048
|
}
|
|
1926
2049
|
|
|
@@ -2285,7 +2408,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2285
2408
|
const originalRequest = requestOf(source)
|
|
2286
2409
|
let current: RequestSource = source
|
|
2287
2410
|
for (let i = 0; i < hooks.length; i++) {
|
|
2288
|
-
const outcome = (hooks[i] as RawOnRequest)(requestOf(current))
|
|
2411
|
+
const outcome = (hooks[i] as RawOnRequest)(requestOf(current), platform)
|
|
2289
2412
|
if (outcome instanceof Promise) {
|
|
2290
2413
|
return outcome.then((early) =>
|
|
2291
2414
|
this.continueOnRequest(
|
|
@@ -2335,7 +2458,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2335
2458
|
return wrapResponse(early)
|
|
2336
2459
|
}
|
|
2337
2460
|
if (index >= this.onRequestHooks.length) break
|
|
2338
|
-
const outcome = (this.onRequestHooks[index] as RawOnRequest)(requestOf(current))
|
|
2461
|
+
const outcome = (this.onRequestHooks[index] as RawOnRequest)(requestOf(current), platform)
|
|
2339
2462
|
early = outcome instanceof Promise ? await outcome : outcome
|
|
2340
2463
|
index++
|
|
2341
2464
|
}
|
|
@@ -2445,6 +2568,13 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2445
2568
|
if (key === null || !validIdempotencyKey(key)) {
|
|
2446
2569
|
return wrapResponse(jsonError(400, "idempotency_key_required"))
|
|
2447
2570
|
}
|
|
2571
|
+
const namespace =
|
|
2572
|
+
typeof config.namespace === "function"
|
|
2573
|
+
? await config.namespace(req.clone(), platform)
|
|
2574
|
+
: config.namespace
|
|
2575
|
+
if (!validIdempotencyNamespace(namespace)) {
|
|
2576
|
+
return wrapResponse(jsonError(500, "idempotency_namespace_invalid"))
|
|
2577
|
+
}
|
|
2448
2578
|
const read = await readBoundedBytes(req, this.maxBodyBytes)
|
|
2449
2579
|
if (!read.ok) {
|
|
2450
2580
|
return wrapResponse(
|
|
@@ -2452,22 +2582,34 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2452
2582
|
)
|
|
2453
2583
|
}
|
|
2454
2584
|
const url = urlPartsOf(req.url)
|
|
2585
|
+
const canonicalBody = canonicalizeIdempotencyBody(read.bytes, req.headers.get("content-type"))
|
|
2455
2586
|
const fingerprint = await computeIdempotencyFingerprint(
|
|
2456
2587
|
req.method,
|
|
2457
2588
|
`${url.pathname}${url.search}`,
|
|
2458
|
-
|
|
2589
|
+
canonicalBody,
|
|
2590
|
+
req.headers.get("content-type") ?? "",
|
|
2459
2591
|
)
|
|
2460
2592
|
|
|
2461
|
-
const begin = await config.store.begin(key, fingerprint, config.ttlMs)
|
|
2462
|
-
if (begin.state === "replay")
|
|
2593
|
+
const begin = await config.store.begin({ namespace, key, fingerprint, ttlMs: config.ttlMs })
|
|
2594
|
+
if (begin.state === "replay") {
|
|
2595
|
+
return wrapResponse(responseFromStored(begin.response, { maxBytes: config.maxResponseBytes }))
|
|
2596
|
+
}
|
|
2463
2597
|
if (begin.state === "mismatch") return wrapResponse(jsonError(409, "idempotency_key_reused"))
|
|
2464
2598
|
if (begin.state === "in-flight") {
|
|
2465
2599
|
return wrapResponse(jsonError(409, "idempotency_in_progress", { "Retry-After": "1" }))
|
|
2466
2600
|
}
|
|
2601
|
+
if (begin.state === "capacity") {
|
|
2602
|
+
return wrapResponse(jsonError(503, "idempotency_store_capacity", { "Retry-After": "1" }))
|
|
2603
|
+
}
|
|
2604
|
+
const reservation = begin.reservation
|
|
2467
2605
|
|
|
2468
2606
|
// Fresh key: re-expose the buffered body as a Request so the normal lanes can read + validate it,
|
|
2469
2607
|
// then run those lanes to a concrete Response.
|
|
2470
|
-
const bufferedInit: RequestInit = {
|
|
2608
|
+
const bufferedInit: RequestInit = {
|
|
2609
|
+
method: req.method,
|
|
2610
|
+
headers: req.headers,
|
|
2611
|
+
signal: req.signal,
|
|
2612
|
+
}
|
|
2471
2613
|
if (read.bytes.byteLength > 0)
|
|
2472
2614
|
bufferedInit.body = read.bytes as NonNullable<RequestInit["body"]>
|
|
2473
2615
|
const buffered: RequestSource = new Request(req.url, bufferedInit)
|
|
@@ -2487,14 +2629,44 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2487
2629
|
} catch (err) {
|
|
2488
2630
|
// The lanes never throw (errors resolve to a 500 Response), but stay fail-safe: release the
|
|
2489
2631
|
// reservation so a retry isn't wedged, then re-throw.
|
|
2490
|
-
await config.store.abandon(key)
|
|
2632
|
+
await config.store.abandon({ namespace, key, reservation })
|
|
2491
2633
|
throw err
|
|
2492
2634
|
}
|
|
2493
2635
|
// Cache only successful responses; release the key on any error so the client can retry.
|
|
2494
2636
|
if (response.status >= 200 && response.status < 300) {
|
|
2495
|
-
|
|
2637
|
+
let storedResponse: Awaited<ReturnType<typeof serializeResponse>>
|
|
2638
|
+
try {
|
|
2639
|
+
storedResponse = await serializeResponse(response, { maxBytes: config.maxResponseBytes })
|
|
2640
|
+
} catch (error) {
|
|
2641
|
+
if (!(error instanceof IdempotencyResponseTooLargeError)) throw error
|
|
2642
|
+
// The effect may already have happened, so never abandon and permit a duplicate execution.
|
|
2643
|
+
// Commit a small terminal response under the winning key and return that same response now.
|
|
2644
|
+
response = jsonError(507, "idempotency_response_too_large")
|
|
2645
|
+
try {
|
|
2646
|
+
storedResponse = await serializeResponse(response, {
|
|
2647
|
+
maxBytes: config.maxResponseBytes,
|
|
2648
|
+
})
|
|
2649
|
+
} catch (terminalError) {
|
|
2650
|
+
if (!(terminalError instanceof IdempotencyResponseTooLargeError)) throw terminalError
|
|
2651
|
+
// Even an intentionally tiny bound must remain truthful. An empty 507 preserves terminal
|
|
2652
|
+
// status + replay safety without silently storing more bytes than the route permits.
|
|
2653
|
+
response = new Response(null, { status: 507 })
|
|
2654
|
+
storedResponse = await serializeResponse(response, {
|
|
2655
|
+
maxBytes: config.maxResponseBytes,
|
|
2656
|
+
})
|
|
2657
|
+
}
|
|
2658
|
+
}
|
|
2659
|
+
const completed = await config.store.complete({
|
|
2660
|
+
namespace,
|
|
2661
|
+
key,
|
|
2662
|
+
reservation,
|
|
2663
|
+
response: storedResponse,
|
|
2664
|
+
})
|
|
2665
|
+
if (!completed) {
|
|
2666
|
+
return wrapResponse(jsonError(503, "idempotency_reservation_lost", { "Retry-After": "1" }))
|
|
2667
|
+
}
|
|
2496
2668
|
} else {
|
|
2497
|
-
await config.store.abandon(key)
|
|
2669
|
+
await config.store.abandon({ namespace, key, reservation })
|
|
2498
2670
|
}
|
|
2499
2671
|
return wrapResponse(response)
|
|
2500
2672
|
}
|
|
@@ -2584,6 +2756,20 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2584
2756
|
platform,
|
|
2585
2757
|
this.maxBodyBytes,
|
|
2586
2758
|
)
|
|
2759
|
+
// Ledgered routes (capabilities + effectLedger) carry a bounded per-request ledger; the
|
|
2760
|
+
// `useCapability` beacon appends to it, and the settle step below seals + sinks it. The route
|
|
2761
|
+
// was forced off the fused/contextless lanes at registration, so this branch always runs.
|
|
2762
|
+
let ledger: RequestLedger | undefined
|
|
2763
|
+
if (entry.ledgered !== undefined) {
|
|
2764
|
+
ledger = createRequestLedger({
|
|
2765
|
+
method: entry.ledgered.method,
|
|
2766
|
+
path: entry.ledgered.path,
|
|
2767
|
+
declared: entry.ledgered.declared,
|
|
2768
|
+
maxEntries: entry.ledgered.maxEntries,
|
|
2769
|
+
chain: entry.ledgered.chain,
|
|
2770
|
+
})
|
|
2771
|
+
attachEffectLedger(ctx, ledger)
|
|
2772
|
+
}
|
|
2587
2773
|
|
|
2588
2774
|
if (entry.around.length === 0) {
|
|
2589
2775
|
outcome = entry.bare
|
|
@@ -2609,6 +2795,16 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2609
2795
|
wrapResponse,
|
|
2610
2796
|
)
|
|
2611
2797
|
}
|
|
2798
|
+
// Settle step: seal the ledger and hand it to the sink BEFORE the response is released, on
|
|
2799
|
+
// success and error responses alike (partial work is exactly what an audit must capture). The
|
|
2800
|
+
// lanes above resolve handler errors to responses, so a plain resolve arm is sufficient.
|
|
2801
|
+
if (ledger !== undefined) {
|
|
2802
|
+
const resolved = entry.ledgered as ResolvedEffectLedger
|
|
2803
|
+
const active = ledger
|
|
2804
|
+
outcome = (outcome instanceof Promise ? outcome : Promise.resolve(outcome)).then((value) =>
|
|
2805
|
+
this.settleEffectLedger(active, resolved, value),
|
|
2806
|
+
)
|
|
2807
|
+
}
|
|
2612
2808
|
}
|
|
2613
2809
|
// The request timeout only bounds work that is actually pending — a synchronous (bare) result is
|
|
2614
2810
|
// already complete and can't time out, so it's returned as-is (no 503 race, no promise).
|
|
@@ -2627,6 +2823,34 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2627
2823
|
return outcome
|
|
2628
2824
|
}
|
|
2629
2825
|
|
|
2826
|
+
/**
|
|
2827
|
+
* Seal a ledgered request and deliver the sealed ledger to the sink. An empty ledger (declared
|
|
2828
|
+
* capabilities, none exercised) is not delivered — there is nothing to audit. This post-effect
|
|
2829
|
+
* ledger is observational: transactional/durable audit belongs in
|
|
2830
|
+
* the owned effect transaction, so a sink outage must not turn a successful effect into a retryable
|
|
2831
|
+
* 500 that can duplicate it.
|
|
2832
|
+
*/
|
|
2833
|
+
private async settleEffectLedger<T>(
|
|
2834
|
+
ledger: RequestLedger,
|
|
2835
|
+
resolved: ResolvedEffectLedger,
|
|
2836
|
+
value: T,
|
|
2837
|
+
): Promise<T> {
|
|
2838
|
+
const sealed = await ledger.seal()
|
|
2839
|
+
if (sealed.entries.length === 0) return value
|
|
2840
|
+
try {
|
|
2841
|
+
await resolved.sink(sealed)
|
|
2842
|
+
} catch (err) {
|
|
2843
|
+
// Token-only logging: the route pattern and the failure name — never entry payloads (there are
|
|
2844
|
+
// none) and never the request.
|
|
2845
|
+
this.logger.error("effect ledger sink failed", {
|
|
2846
|
+
method: resolved.method,
|
|
2847
|
+
path: resolved.path,
|
|
2848
|
+
name: err instanceof Error ? err.name : "Error",
|
|
2849
|
+
})
|
|
2850
|
+
}
|
|
2851
|
+
return value
|
|
2852
|
+
}
|
|
2853
|
+
|
|
2630
2854
|
/** The narrowest bare route: a syntactic `() => ...` handler cannot observe the context argument, so
|
|
2631
2855
|
* successful requests can skip allocating `RequestContext`. Errors still allocate one for logging. */
|
|
2632
2856
|
private runContextlessBare<T>(
|