@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
package/src/server/server.ts
CHANGED
|
@@ -5,8 +5,27 @@ import {
|
|
|
5
5
|
type DeadlineAdmissionOptions,
|
|
6
6
|
NIFRA_DEADLINE_HEADER,
|
|
7
7
|
type RequestBudget,
|
|
8
|
-
} from "
|
|
8
|
+
} from "../budget.ts"
|
|
9
9
|
import { FrameworkError, RouteConfigError } from "../errors.ts"
|
|
10
|
+
import {
|
|
11
|
+
canonicalizeIdempotencyBody,
|
|
12
|
+
computeIdempotencyFingerprint,
|
|
13
|
+
DEFAULT_IDEMPOTENCY_HEADER,
|
|
14
|
+
DEFAULT_IDEMPOTENCY_TTL_MS,
|
|
15
|
+
IdempotencyResponseTooLargeError,
|
|
16
|
+
type IdempotencyStore,
|
|
17
|
+
MemoryIdempotencyStore,
|
|
18
|
+
responseFromStored,
|
|
19
|
+
serializeResponse,
|
|
20
|
+
validIdempotencyKey,
|
|
21
|
+
validIdempotencyNamespace,
|
|
22
|
+
} from "../idempotency.ts"
|
|
23
|
+
import {
|
|
24
|
+
CAPABILITY_GUARD,
|
|
25
|
+
type CapabilityUseEvent,
|
|
26
|
+
createCapabilityGuard,
|
|
27
|
+
normalizeRouteCapabilities,
|
|
28
|
+
} from "../internal/capability-runtime.ts"
|
|
10
29
|
import {
|
|
11
30
|
type AssuranceDeclaration,
|
|
12
31
|
type AssuranceEvidence,
|
|
@@ -14,6 +33,13 @@ import {
|
|
|
14
33
|
assuranceEvidenceFor,
|
|
15
34
|
NIFRA_ASSURANCE_IDS,
|
|
16
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"
|
|
17
43
|
import { EMPTY_PARAMS, type Method, Router } from "../router/router.ts"
|
|
18
44
|
import type {
|
|
19
45
|
InferOutput,
|
|
@@ -148,17 +174,42 @@ type RawAfterHandle = (result: unknown, ctx: RawContext) => MaybePromise<unknown
|
|
|
148
174
|
type RawErrorHandler = (error: unknown, ctx: RawContext) => MaybePromise<unknown>
|
|
149
175
|
type RawAround = <T>(ctx: RawContext, next: () => MaybePromise<T>) => MaybePromise<T>
|
|
150
176
|
export type OnRequestResult = Response | Request | undefined
|
|
151
|
-
type RawOnRequest = (req: Request) => MaybePromise<OnRequestResult>
|
|
177
|
+
type RawOnRequest = (req: Request, platform?: Platform) => MaybePromise<OnRequestResult>
|
|
152
178
|
type RawOnResponse = (response: Response, req: Request) => MaybePromise<Response>
|
|
153
179
|
type RawOnResponseFinalized = (outcome: ResponseFinalization, req: Request) => MaybePromise<void>
|
|
154
180
|
|
|
181
|
+
/** Registration-resolved idempotency for a route: the config with its store + defaults pinned. */
|
|
182
|
+
interface ResolvedIdempotency {
|
|
183
|
+
readonly store: IdempotencyStore
|
|
184
|
+
readonly ttlMs: number
|
|
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[]
|
|
200
|
+
}
|
|
201
|
+
|
|
155
202
|
interface RouteEntry {
|
|
156
203
|
readonly handler: InternalHandler
|
|
157
204
|
readonly schema: RouteSchema | undefined
|
|
205
|
+
/** Resolved idempotency config; `undefined` = off (the dedupe lane is never entered). */
|
|
206
|
+
readonly idempotent: ResolvedIdempotency | undefined
|
|
207
|
+
/** Resolved effect-ledger wiring; `undefined` = off (no per-request ledger, no settle step). */
|
|
208
|
+
readonly ledgered: ResolvedEffectLedger | undefined
|
|
158
209
|
/** Per-request context extensions captured at registration (order-scoped). */
|
|
159
210
|
readonly derives: ReadonlyArray<RawDerive>
|
|
160
211
|
/** Static context extensions captured at registration. */
|
|
161
|
-
readonly decorations: Record<
|
|
212
|
+
readonly decorations: Record<PropertyKey, unknown>
|
|
162
213
|
/** Whether {@link decorations} has any keys — precomputed so the hot path skips a no-op
|
|
163
214
|
* `Object.assign` on the (common) no-decoration route. */
|
|
164
215
|
readonly hasDecorations: boolean
|
|
@@ -313,6 +364,27 @@ export interface ServerOptions {
|
|
|
313
364
|
* shared matched lane where the gate runs); when unset, the request path pays nothing.
|
|
314
365
|
*/
|
|
315
366
|
readonly admission?: AdmissionController
|
|
367
|
+
/**
|
|
368
|
+
* Optional token-only observation hook called by `useCapability(c, id)`. It receives no request,
|
|
369
|
+
* parameters, body, tenant, or values. Routes without capability declarations keep the old hot path.
|
|
370
|
+
*/
|
|
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
|
|
382
|
+
/**
|
|
383
|
+
* Default store backing routes that declare `schema.idempotency` without their own `store`. Defaults
|
|
384
|
+
* to a shared in-process {@link MemoryIdempotencyStore} (created lazily on first idempotent route).
|
|
385
|
+
* Inject a durable store here to make request-scoped dedupe survive restarts app-wide.
|
|
386
|
+
*/
|
|
387
|
+
readonly idempotencyStore?: IdempotencyStore
|
|
316
388
|
/** Structured logger for framework events (redacts secrets). Default: JSON to stderr. */
|
|
317
389
|
readonly logger?: Logger
|
|
318
390
|
/**
|
|
@@ -354,6 +426,8 @@ export interface RouteDescriptor {
|
|
|
354
426
|
readonly schema: RouteSchema | undefined
|
|
355
427
|
/** Effective enforcement evidence, populated only during reflection/introspection. */
|
|
356
428
|
readonly assurance?: readonly AssuranceEvidence[]
|
|
429
|
+
/** Normalized declared effect tokens, populated only when the route declares any. */
|
|
430
|
+
readonly capabilities?: readonly string[]
|
|
357
431
|
readonly tool?: {
|
|
358
432
|
readonly name: string
|
|
359
433
|
readonly description: string
|
|
@@ -412,7 +486,7 @@ export interface RunningServer {
|
|
|
412
486
|
*/
|
|
413
487
|
export interface Middleware {
|
|
414
488
|
readonly name?: string
|
|
415
|
-
readonly onRequest?: (req: Request) => MaybePromise<OnRequestResult>
|
|
489
|
+
readonly onRequest?: (req: Request, platform?: Platform) => MaybePromise<OnRequestResult>
|
|
416
490
|
readonly around?: <T>(context: Context, next: () => MaybePromise<T>) => MaybePromise<T>
|
|
417
491
|
readonly beforeHandle?: (context: Context) => MaybePromise<unknown>
|
|
418
492
|
readonly afterHandle?: (result: unknown, context: Context) => MaybePromise<unknown>
|
|
@@ -1233,6 +1307,14 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1233
1307
|
private readonly gracefulSignals: boolean
|
|
1234
1308
|
/** Capacity-admission gate; `undefined` = off (the request path pays nothing). */
|
|
1235
1309
|
private readonly capacityGate: AdmissionController | undefined
|
|
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
|
|
1315
|
+
/** App-wide idempotency store (explicit option, else a lazily-created in-process default). */
|
|
1316
|
+
private readonly configuredIdempotencyStore: IdempotencyStore | undefined
|
|
1317
|
+
private lazyIdempotencyStore: IdempotencyStore | undefined
|
|
1236
1318
|
private readonly logger: Logger
|
|
1237
1319
|
/** App-wide validation-error fallback; a route's own `schema.onValidationError` takes precedence. */
|
|
1238
1320
|
private readonly defaultOnValidationError?: RouteSchema["onValidationError"]
|
|
@@ -1293,6 +1375,26 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1293
1375
|
}
|
|
1294
1376
|
this.gracefulSignals = options.gracefulSignals ?? false
|
|
1295
1377
|
this.capacityGate = options.admission
|
|
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
|
+
}
|
|
1396
|
+
this.configuredIdempotencyStore = options.idempotencyStore
|
|
1397
|
+
this.lazyIdempotencyStore = undefined
|
|
1296
1398
|
this.logger = options.logger ?? jsonLogger()
|
|
1297
1399
|
this.defaultOnValidationError = options.onValidationError
|
|
1298
1400
|
this.bunServer = undefined
|
|
@@ -1330,8 +1432,10 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1330
1432
|
* Run before routing on the raw request. Return a `Response` to short-circuit, or a replacement
|
|
1331
1433
|
* `Request` to continue routing with a rewritten method/URL/headers. Global.
|
|
1332
1434
|
*/
|
|
1333
|
-
onRequest(
|
|
1334
|
-
|
|
1435
|
+
onRequest(
|
|
1436
|
+
fn: (req: Request, platform?: Platform<EnvOf<Ctx>>) => MaybePromise<OnRequestResult>,
|
|
1437
|
+
): this {
|
|
1438
|
+
this.onRequestHooks.push(fn as RawOnRequest)
|
|
1335
1439
|
return this
|
|
1336
1440
|
}
|
|
1337
1441
|
|
|
@@ -1744,9 +1848,48 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1744
1848
|
schema: RouteSchema | undefined,
|
|
1745
1849
|
handler: (context: never) => unknown,
|
|
1746
1850
|
): void {
|
|
1851
|
+
const capabilities = normalizeRouteCapabilities(schema?.capabilities)
|
|
1852
|
+
const handlerAssurance = assuranceDeclarationsOf(handler as unknown as object)
|
|
1853
|
+
const invalidHandlerScope = handlerAssurance.find(
|
|
1854
|
+
(declaration) => declaration.scope !== "plugin",
|
|
1855
|
+
)
|
|
1856
|
+
if (invalidHandlerScope !== undefined) {
|
|
1857
|
+
throw new RouteConfigError(
|
|
1858
|
+
"INVALID_ASSURANCE",
|
|
1859
|
+
`route handler assurance must use plugin scope (received ${invalidHandlerScope.scope})`,
|
|
1860
|
+
)
|
|
1861
|
+
}
|
|
1862
|
+
const authenticated = assuranceEvidenceFor(
|
|
1863
|
+
[...this.activeAssurance, ...handlerAssurance, ...this.globalAssurance],
|
|
1864
|
+
method,
|
|
1865
|
+
path,
|
|
1866
|
+
).some((evidence) => evidence.id === NIFRA_ASSURANCE_IDS.AUTHENTICATED)
|
|
1867
|
+
const routeDecorations: Record<PropertyKey, unknown> = { ...this.decorations }
|
|
1868
|
+
if (capabilities.length > 0) {
|
|
1869
|
+
routeDecorations[CAPABILITY_GUARD] = createCapabilityGuard(
|
|
1870
|
+
capabilities,
|
|
1871
|
+
method,
|
|
1872
|
+
path,
|
|
1873
|
+
this.onCapabilityUse,
|
|
1874
|
+
)
|
|
1875
|
+
}
|
|
1876
|
+
const hasDecorations = Reflect.ownKeys(routeDecorations).length > 0
|
|
1877
|
+
// An idempotency route runs a dedupe lane that must buffer the body and capture the response, so it
|
|
1878
|
+
// never takes the fused/native fast path — force it onto the portable matched lane (which routes
|
|
1879
|
+
// through `fetchMatched`, where the dedupe wrapper lives).
|
|
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
|
|
1747
1888
|
const bare =
|
|
1748
1889
|
schema?.body === undefined &&
|
|
1749
1890
|
schema?.query === undefined &&
|
|
1891
|
+
idempotent === undefined &&
|
|
1892
|
+
ledgered === undefined &&
|
|
1750
1893
|
this.derives.length === 0 &&
|
|
1751
1894
|
this.beforeHandleHooks.length === 0 &&
|
|
1752
1895
|
this.afterHandleHooks.length === 0 &&
|
|
@@ -1755,7 +1898,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1755
1898
|
bare && this.aroundHooks.length === 0
|
|
1756
1899
|
? this.buildFusedWeb(
|
|
1757
1900
|
handler as unknown as InternalHandler,
|
|
1758
|
-
|
|
1901
|
+
hasDecorations ? routeDecorations : undefined,
|
|
1759
1902
|
isContextlessNoArgArrow(handler),
|
|
1760
1903
|
)
|
|
1761
1904
|
: undefined
|
|
@@ -1764,10 +1907,12 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1764
1907
|
// with the concrete RawContext the typed handler expects, so this is sound.
|
|
1765
1908
|
handler: handler as unknown as InternalHandler,
|
|
1766
1909
|
schema,
|
|
1910
|
+
idempotent,
|
|
1911
|
+
ledgered,
|
|
1767
1912
|
fusedWeb,
|
|
1768
1913
|
derives: [...this.derives],
|
|
1769
|
-
decorations:
|
|
1770
|
-
hasDecorations
|
|
1914
|
+
decorations: routeDecorations,
|
|
1915
|
+
hasDecorations,
|
|
1771
1916
|
beforeHandle: [...this.beforeHandleHooks],
|
|
1772
1917
|
afterHandle: [...this.afterHandleHooks],
|
|
1773
1918
|
onError: [...this.onErrorHooks],
|
|
@@ -1794,9 +1939,14 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1794
1939
|
this.onErrorHooks.length === 0,
|
|
1795
1940
|
}
|
|
1796
1941
|
this.router.add(method, path, entry)
|
|
1797
|
-
const descriptor: RouteDescriptor = {
|
|
1942
|
+
const descriptor: RouteDescriptor = {
|
|
1943
|
+
method,
|
|
1944
|
+
path,
|
|
1945
|
+
schema,
|
|
1946
|
+
...(capabilities.length > 0 ? { capabilities } : {}),
|
|
1947
|
+
}
|
|
1798
1948
|
this.routeList.push(descriptor)
|
|
1799
|
-
const routeAssurance: AssuranceDeclaration[] = [...this.activeAssurance]
|
|
1949
|
+
const routeAssurance: AssuranceDeclaration[] = [...this.activeAssurance, ...handlerAssurance]
|
|
1800
1950
|
if (schema?.body !== undefined) {
|
|
1801
1951
|
routeAssurance.push(
|
|
1802
1952
|
Object.freeze({
|
|
@@ -1806,6 +1956,17 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1806
1956
|
}),
|
|
1807
1957
|
)
|
|
1808
1958
|
}
|
|
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.
|
|
1961
|
+
if (schema?.idempotency !== undefined) {
|
|
1962
|
+
routeAssurance.push(
|
|
1963
|
+
Object.freeze({
|
|
1964
|
+
id: NIFRA_ASSURANCE_IDS.IDEMPOTENCY_KEY,
|
|
1965
|
+
source: "route-schema",
|
|
1966
|
+
scope: "plugin",
|
|
1967
|
+
}),
|
|
1968
|
+
)
|
|
1969
|
+
}
|
|
1809
1970
|
this.rawEntries.push({
|
|
1810
1971
|
method,
|
|
1811
1972
|
path,
|
|
@@ -1815,6 +1976,84 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1815
1976
|
})
|
|
1816
1977
|
}
|
|
1817
1978
|
|
|
1979
|
+
/** Resolve a route's declared idempotency into a pinned store + defaults, or `undefined` when off. */
|
|
1980
|
+
private resolveIdempotency(
|
|
1981
|
+
schema: RouteSchema | undefined,
|
|
1982
|
+
authenticated: boolean,
|
|
1983
|
+
): ResolvedIdempotency | undefined {
|
|
1984
|
+
const config = schema?.idempotency
|
|
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
|
+
}
|
|
2041
|
+
return Object.freeze({
|
|
2042
|
+
store,
|
|
2043
|
+
ttlMs,
|
|
2044
|
+
headerName,
|
|
2045
|
+
namespace,
|
|
2046
|
+
maxResponseBytes,
|
|
2047
|
+
})
|
|
2048
|
+
}
|
|
2049
|
+
|
|
2050
|
+
/** The app-wide idempotency store: the configured one, else a lazily-created shared in-process store. */
|
|
2051
|
+
private defaultIdempotencyStore(): IdempotencyStore {
|
|
2052
|
+
if (this.configuredIdempotencyStore !== undefined) return this.configuredIdempotencyStore
|
|
2053
|
+
this.lazyIdempotencyStore ??= new MemoryIdempotencyStore()
|
|
2054
|
+
return this.lazyIdempotencyStore
|
|
2055
|
+
}
|
|
2056
|
+
|
|
1818
2057
|
/**
|
|
1819
2058
|
* Merge another server's routes into this one — the composition escape hatch for large apps.
|
|
1820
2059
|
*
|
|
@@ -1986,18 +2225,10 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1986
2225
|
release()
|
|
1987
2226
|
throw error
|
|
1988
2227
|
}
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
return response
|
|
1994
|
-
},
|
|
1995
|
-
(error) => {
|
|
1996
|
-
release()
|
|
1997
|
-
throw error
|
|
1998
|
-
},
|
|
1999
|
-
)
|
|
2000
|
-
}
|
|
2228
|
+
// Release the slot once the response settles — on resolve OR reject — via `finally`, which passes
|
|
2229
|
+
// the value/rejection through unchanged. (A single settle hook, rather than separate then-arms: the
|
|
2230
|
+
// request pipeline resolves handler errors to a Response, so a rejection arm would be unreachable.)
|
|
2231
|
+
if (outcome instanceof Promise) return outcome.finally(release)
|
|
2001
2232
|
release()
|
|
2002
2233
|
return outcome
|
|
2003
2234
|
}
|
|
@@ -2177,7 +2408,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2177
2408
|
const originalRequest = requestOf(source)
|
|
2178
2409
|
let current: RequestSource = source
|
|
2179
2410
|
for (let i = 0; i < hooks.length; i++) {
|
|
2180
|
-
const outcome = (hooks[i] as RawOnRequest)(requestOf(current))
|
|
2411
|
+
const outcome = (hooks[i] as RawOnRequest)(requestOf(current), platform)
|
|
2181
2412
|
if (outcome instanceof Promise) {
|
|
2182
2413
|
return outcome.then((early) =>
|
|
2183
2414
|
this.continueOnRequest(
|
|
@@ -2227,7 +2458,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2227
2458
|
return wrapResponse(early)
|
|
2228
2459
|
}
|
|
2229
2460
|
if (index >= this.onRequestHooks.length) break
|
|
2230
|
-
const outcome = (this.onRequestHooks[index] as RawOnRequest)(requestOf(current))
|
|
2461
|
+
const outcome = (this.onRequestHooks[index] as RawOnRequest)(requestOf(current), platform)
|
|
2231
2462
|
early = outcome instanceof Promise ? await outcome : outcome
|
|
2232
2463
|
index++
|
|
2233
2464
|
}
|
|
@@ -2297,6 +2528,160 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2297
2528
|
wrapResponse: (response: Response) => T,
|
|
2298
2529
|
onTimeout: () => T,
|
|
2299
2530
|
webFast: boolean,
|
|
2531
|
+
): MaybePromise<T> {
|
|
2532
|
+
// An idempotency route runs its dedupe lane first; on a fresh key it delegates to the normal lanes
|
|
2533
|
+
// (with the body buffered). All non-idempotent routes skip straight to the lanes — no added cost.
|
|
2534
|
+
if (entry.idempotent !== undefined) {
|
|
2535
|
+
return this.runIdempotent(source, platform, entry, params, search, wrapResponse)
|
|
2536
|
+
}
|
|
2537
|
+
return this.runMatchedLanes(
|
|
2538
|
+
source,
|
|
2539
|
+
platform,
|
|
2540
|
+
entry,
|
|
2541
|
+
params,
|
|
2542
|
+
search,
|
|
2543
|
+
finalize,
|
|
2544
|
+
wrapResponse,
|
|
2545
|
+
onTimeout,
|
|
2546
|
+
webFast,
|
|
2547
|
+
)
|
|
2548
|
+
}
|
|
2549
|
+
|
|
2550
|
+
/**
|
|
2551
|
+
* Idempotency dedupe lane. Reads the `Idempotency-Key`, fingerprints the request, and consults the
|
|
2552
|
+
* store: a fresh key runs the handler (via {@link runMatchedLanes} with the body buffered) and stores
|
|
2553
|
+
* the response; a repeat key replays it without re-running; a key reused with a different body or an
|
|
2554
|
+
* in-flight duplicate is rejected. Every branch materializes a `Response`, then adapts to `T` via the
|
|
2555
|
+
* caller's `wrapResponse`, so it works on the Web fetch path and the Node-native outcome path alike.
|
|
2556
|
+
*/
|
|
2557
|
+
private async runIdempotent<T>(
|
|
2558
|
+
source: RequestSource,
|
|
2559
|
+
platform: Platform | undefined,
|
|
2560
|
+
entry: RouteEntry,
|
|
2561
|
+
params: Record<string, string>,
|
|
2562
|
+
search: string | undefined,
|
|
2563
|
+
wrapResponse: (response: Response) => T,
|
|
2564
|
+
): Promise<T> {
|
|
2565
|
+
const config = entry.idempotent as ResolvedIdempotency
|
|
2566
|
+
const req = requestOf(source)
|
|
2567
|
+
const key = req.headers.get(config.headerName)
|
|
2568
|
+
if (key === null || !validIdempotencyKey(key)) {
|
|
2569
|
+
return wrapResponse(jsonError(400, "idempotency_key_required"))
|
|
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
|
+
}
|
|
2578
|
+
const read = await readBoundedBytes(req, this.maxBodyBytes)
|
|
2579
|
+
if (!read.ok) {
|
|
2580
|
+
return wrapResponse(
|
|
2581
|
+
jsonError(read.status, read.status === 413 ? "payload_too_large" : "bad_request"),
|
|
2582
|
+
)
|
|
2583
|
+
}
|
|
2584
|
+
const url = urlPartsOf(req.url)
|
|
2585
|
+
const canonicalBody = canonicalizeIdempotencyBody(read.bytes, req.headers.get("content-type"))
|
|
2586
|
+
const fingerprint = await computeIdempotencyFingerprint(
|
|
2587
|
+
req.method,
|
|
2588
|
+
`${url.pathname}${url.search}`,
|
|
2589
|
+
canonicalBody,
|
|
2590
|
+
req.headers.get("content-type") ?? "",
|
|
2591
|
+
)
|
|
2592
|
+
|
|
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
|
+
}
|
|
2597
|
+
if (begin.state === "mismatch") return wrapResponse(jsonError(409, "idempotency_key_reused"))
|
|
2598
|
+
if (begin.state === "in-flight") {
|
|
2599
|
+
return wrapResponse(jsonError(409, "idempotency_in_progress", { "Retry-After": "1" }))
|
|
2600
|
+
}
|
|
2601
|
+
if (begin.state === "capacity") {
|
|
2602
|
+
return wrapResponse(jsonError(503, "idempotency_store_capacity", { "Retry-After": "1" }))
|
|
2603
|
+
}
|
|
2604
|
+
const reservation = begin.reservation
|
|
2605
|
+
|
|
2606
|
+
// Fresh key: re-expose the buffered body as a Request so the normal lanes can read + validate it,
|
|
2607
|
+
// then run those lanes to a concrete Response.
|
|
2608
|
+
const bufferedInit: RequestInit = {
|
|
2609
|
+
method: req.method,
|
|
2610
|
+
headers: req.headers,
|
|
2611
|
+
signal: req.signal,
|
|
2612
|
+
}
|
|
2613
|
+
if (read.bytes.byteLength > 0)
|
|
2614
|
+
bufferedInit.body = read.bytes as NonNullable<RequestInit["body"]>
|
|
2615
|
+
const buffered: RequestSource = new Request(req.url, bufferedInit)
|
|
2616
|
+
let response: Response
|
|
2617
|
+
try {
|
|
2618
|
+
response = await this.runMatchedLanes(
|
|
2619
|
+
buffered,
|
|
2620
|
+
platform,
|
|
2621
|
+
entry,
|
|
2622
|
+
params,
|
|
2623
|
+
search,
|
|
2624
|
+
toResponse,
|
|
2625
|
+
IDENTITY_RESPONSE,
|
|
2626
|
+
RESPONSE_TIMEOUT,
|
|
2627
|
+
false,
|
|
2628
|
+
)
|
|
2629
|
+
} catch (err) {
|
|
2630
|
+
// The lanes never throw (errors resolve to a 500 Response), but stay fail-safe: release the
|
|
2631
|
+
// reservation so a retry isn't wedged, then re-throw.
|
|
2632
|
+
await config.store.abandon({ namespace, key, reservation })
|
|
2633
|
+
throw err
|
|
2634
|
+
}
|
|
2635
|
+
// Cache only successful responses; release the key on any error so the client can retry.
|
|
2636
|
+
if (response.status >= 200 && response.status < 300) {
|
|
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
|
+
}
|
|
2668
|
+
} else {
|
|
2669
|
+
await config.store.abandon({ namespace, key, reservation })
|
|
2670
|
+
}
|
|
2671
|
+
return wrapResponse(response)
|
|
2672
|
+
}
|
|
2673
|
+
|
|
2674
|
+
/** Run a matched route through its selected execution lane (fused/bare/bodyOnly/queryOnly/lifecycle). */
|
|
2675
|
+
private runMatchedLanes<T>(
|
|
2676
|
+
source: RequestSource,
|
|
2677
|
+
platform: Platform | undefined,
|
|
2678
|
+
entry: RouteEntry,
|
|
2679
|
+
params: Record<string, string>,
|
|
2680
|
+
search: string | undefined,
|
|
2681
|
+
finalize: (result: unknown, set: CtxSet) => T,
|
|
2682
|
+
wrapResponse: (response: Response) => T,
|
|
2683
|
+
onTimeout: () => T,
|
|
2684
|
+
webFast: boolean,
|
|
2300
2685
|
): MaybePromise<T> {
|
|
2301
2686
|
// Translate the absolute wire deadline once, clamp it to local policy, then use the resulting
|
|
2302
2687
|
// duration for both c.signal and c.budget. A client can only shorten work, never extend it.
|
|
@@ -2371,6 +2756,20 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2371
2756
|
platform,
|
|
2372
2757
|
this.maxBodyBytes,
|
|
2373
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
|
+
}
|
|
2374
2773
|
|
|
2375
2774
|
if (entry.around.length === 0) {
|
|
2376
2775
|
outcome = entry.bare
|
|
@@ -2396,6 +2795,16 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2396
2795
|
wrapResponse,
|
|
2397
2796
|
)
|
|
2398
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
|
+
}
|
|
2399
2808
|
}
|
|
2400
2809
|
// The request timeout only bounds work that is actually pending — a synchronous (bare) result is
|
|
2401
2810
|
// already complete and can't time out, so it's returned as-is (no 503 race, no promise).
|
|
@@ -2414,6 +2823,34 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2414
2823
|
return outcome
|
|
2415
2824
|
}
|
|
2416
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
|
+
|
|
2417
2854
|
/** The narrowest bare route: a syntactic `() => ...` handler cannot observe the context argument, so
|
|
2418
2855
|
* successful requests can skip allocating `RequestContext`. Errors still allocate one for logging. */
|
|
2419
2856
|
private runContextlessBare<T>(
|
|
@@ -2527,7 +2964,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2527
2964
|
*/
|
|
2528
2965
|
private buildFusedWeb(
|
|
2529
2966
|
handler: InternalHandler,
|
|
2530
|
-
decorations: Record<
|
|
2967
|
+
decorations: Record<PropertyKey, unknown> | undefined,
|
|
2531
2968
|
contextless: boolean,
|
|
2532
2969
|
): FusedWebRunner {
|
|
2533
2970
|
const maxBodyBytes = this.maxBodyBytes
|