@nifrajs/core 1.9.0 → 1.10.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 +5 -0
- package/dist/assurance.d.ts.map +1 -1
- package/dist/assurance.js +8 -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 +117 -0
- package/dist/capabilities.d.ts.map +1 -0
- package/dist/capabilities.js +257 -0
- package/dist/capabilities.js.map +1 -0
- package/dist/classification.d.ts +19 -0
- package/dist/classification.d.ts.map +1 -0
- package/dist/classification.js +33 -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 +90 -0
- package/dist/idempotency.d.ts.map +1 -0
- package/dist/idempotency.js +135 -0
- package/dist/idempotency.js.map +1 -0
- package/dist/index.d.ts +5 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -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/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 +34 -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 +75 -0
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +195 -9
- package/dist/server/server.js.map +1 -1
- package/package.json +11 -4
- package/src/assurance.ts +15 -1
- package/src/budget.ts +267 -0
- package/src/capabilities.ts +430 -0
- package/src/classification.ts +44 -0
- package/src/errors.ts +1 -0
- package/src/idempotency.ts +205 -0
- package/src/index.ts +6 -1
- package/src/internal/capability-runtime.ts +49 -0
- package/src/internal/route-assurance.ts +1 -0
- package/src/reflection.ts +14 -0
- package/src/server/context.ts +35 -1
- package/src/server/contract.ts +19 -2
- package/src/server/server.ts +328 -11
package/src/server/server.ts
CHANGED
|
@@ -5,8 +5,24 @@ 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
|
+
computeIdempotencyFingerprint,
|
|
12
|
+
DEFAULT_IDEMPOTENCY_HEADER,
|
|
13
|
+
DEFAULT_IDEMPOTENCY_TTL_MS,
|
|
14
|
+
type IdempotencyStore,
|
|
15
|
+
MemoryIdempotencyStore,
|
|
16
|
+
responseFromStored,
|
|
17
|
+
serializeResponse,
|
|
18
|
+
validIdempotencyKey,
|
|
19
|
+
} from "../idempotency.ts"
|
|
20
|
+
import {
|
|
21
|
+
CAPABILITY_GUARD,
|
|
22
|
+
type CapabilityUseEvent,
|
|
23
|
+
createCapabilityGuard,
|
|
24
|
+
normalizeRouteCapabilities,
|
|
25
|
+
} from "../internal/capability-runtime.ts"
|
|
10
26
|
import {
|
|
11
27
|
type AssuranceDeclaration,
|
|
12
28
|
type AssuranceEvidence,
|
|
@@ -152,13 +168,22 @@ type RawOnRequest = (req: Request) => MaybePromise<OnRequestResult>
|
|
|
152
168
|
type RawOnResponse = (response: Response, req: Request) => MaybePromise<Response>
|
|
153
169
|
type RawOnResponseFinalized = (outcome: ResponseFinalization, req: Request) => MaybePromise<void>
|
|
154
170
|
|
|
171
|
+
/** Registration-resolved idempotency for a route: the config with its store + defaults pinned. */
|
|
172
|
+
interface ResolvedIdempotency {
|
|
173
|
+
readonly store: IdempotencyStore
|
|
174
|
+
readonly ttlMs: number
|
|
175
|
+
readonly headerName: string
|
|
176
|
+
}
|
|
177
|
+
|
|
155
178
|
interface RouteEntry {
|
|
156
179
|
readonly handler: InternalHandler
|
|
157
180
|
readonly schema: RouteSchema | undefined
|
|
181
|
+
/** Resolved idempotency config; `undefined` = off (the dedupe lane is never entered). */
|
|
182
|
+
readonly idempotent: ResolvedIdempotency | undefined
|
|
158
183
|
/** Per-request context extensions captured at registration (order-scoped). */
|
|
159
184
|
readonly derives: ReadonlyArray<RawDerive>
|
|
160
185
|
/** Static context extensions captured at registration. */
|
|
161
|
-
readonly decorations: Record<
|
|
186
|
+
readonly decorations: Record<PropertyKey, unknown>
|
|
162
187
|
/** Whether {@link decorations} has any keys — precomputed so the hot path skips a no-op
|
|
163
188
|
* `Object.assign` on the (common) no-decoration route. */
|
|
164
189
|
readonly hasDecorations: boolean
|
|
@@ -254,6 +279,27 @@ export type Handler<
|
|
|
254
279
|
Ctx = EmptyContext,
|
|
255
280
|
> = (ctx: Context<Path, S> & Ctx) => MaybePromise<ResponseOf<S>>
|
|
256
281
|
|
|
282
|
+
/**
|
|
283
|
+
* The outcome of a capacity-admission decision. `admitted` requests carry a `release` the server calls
|
|
284
|
+
* exactly once when the response is finalized; a shed request carries a ready `429` Response.
|
|
285
|
+
*/
|
|
286
|
+
export type AdmissionDecision =
|
|
287
|
+
| { readonly admitted: true; release(): void }
|
|
288
|
+
| { readonly admitted: false; readonly response: Response }
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* A capacity-admission gate. Decides, per request, whether the instance has capacity to run it now —
|
|
292
|
+
* bounding *concurrency*, which rate limits (frequency) and deadlines (duration) do not. Provide an
|
|
293
|
+
* implementation (see `@nifrajs/middleware`'s `createAdmissionController`) as {@link ServerOptions.admission}.
|
|
294
|
+
*
|
|
295
|
+
* Enabling it trades the fused native-route lane for native matching + this gate (the gate cannot be a
|
|
296
|
+
* per-request `onRequest` hook without disabling native routes entirely); when unset, the request path
|
|
297
|
+
* is untouched.
|
|
298
|
+
*/
|
|
299
|
+
export interface AdmissionController {
|
|
300
|
+
admit(req: Request): AdmissionDecision | Promise<AdmissionDecision>
|
|
301
|
+
}
|
|
302
|
+
|
|
257
303
|
export interface ServerOptions {
|
|
258
304
|
/**
|
|
259
305
|
* Max request body size (bytes), enforced **only when a route declares a body schema** — the cap
|
|
@@ -285,6 +331,24 @@ export interface ServerOptions {
|
|
|
285
331
|
readonly maxInboundDeadlineMs?: number
|
|
286
332
|
/** When `listen()`ing, install SIGTERM/SIGINT handlers that gracefully `stop()`. Default false. */
|
|
287
333
|
readonly gracefulSignals?: boolean
|
|
334
|
+
/**
|
|
335
|
+
* Capacity-admission gate (see {@link AdmissionController}). Bounds concurrent in-flight work so a
|
|
336
|
+
* healthy instance sheds load (`429` + `Retry-After`) instead of accepting more than it can finish.
|
|
337
|
+
* Off by default. Enabling it disables the fused native-route fast path (requests route through the
|
|
338
|
+
* shared matched lane where the gate runs); when unset, the request path pays nothing.
|
|
339
|
+
*/
|
|
340
|
+
readonly admission?: AdmissionController
|
|
341
|
+
/**
|
|
342
|
+
* Optional token-only observation hook called by `useCapability(c, id)`. It receives no request,
|
|
343
|
+
* parameters, body, tenant, or values. Routes without capability declarations keep the old hot path.
|
|
344
|
+
*/
|
|
345
|
+
readonly onCapabilityUse?: (event: CapabilityUseEvent) => void
|
|
346
|
+
/**
|
|
347
|
+
* Default store backing routes that declare `schema.idempotency` without their own `store`. Defaults
|
|
348
|
+
* to a shared in-process {@link MemoryIdempotencyStore} (created lazily on first idempotent route).
|
|
349
|
+
* Inject a durable store here to make request-scoped dedupe survive restarts app-wide.
|
|
350
|
+
*/
|
|
351
|
+
readonly idempotencyStore?: IdempotencyStore
|
|
288
352
|
/** Structured logger for framework events (redacts secrets). Default: JSON to stderr. */
|
|
289
353
|
readonly logger?: Logger
|
|
290
354
|
/**
|
|
@@ -326,6 +390,8 @@ export interface RouteDescriptor {
|
|
|
326
390
|
readonly schema: RouteSchema | undefined
|
|
327
391
|
/** Effective enforcement evidence, populated only during reflection/introspection. */
|
|
328
392
|
readonly assurance?: readonly AssuranceEvidence[]
|
|
393
|
+
/** Normalized declared effect tokens, populated only when the route declares any. */
|
|
394
|
+
readonly capabilities?: readonly string[]
|
|
329
395
|
readonly tool?: {
|
|
330
396
|
readonly name: string
|
|
331
397
|
readonly description: string
|
|
@@ -1203,6 +1269,12 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1203
1269
|
private readonly maxInboundDeadlineMs: number
|
|
1204
1270
|
private readonly deadlineAdmissionOptions: DeadlineAdmissionOptions
|
|
1205
1271
|
private readonly gracefulSignals: boolean
|
|
1272
|
+
/** Capacity-admission gate; `undefined` = off (the request path pays nothing). */
|
|
1273
|
+
private readonly capacityGate: AdmissionController | undefined
|
|
1274
|
+
private readonly onCapabilityUse: ((event: CapabilityUseEvent) => void) | undefined
|
|
1275
|
+
/** App-wide idempotency store (explicit option, else a lazily-created in-process default). */
|
|
1276
|
+
private readonly configuredIdempotencyStore: IdempotencyStore | undefined
|
|
1277
|
+
private lazyIdempotencyStore: IdempotencyStore | undefined
|
|
1206
1278
|
private readonly logger: Logger
|
|
1207
1279
|
/** App-wide validation-error fallback; a route's own `schema.onValidationError` takes precedence. */
|
|
1208
1280
|
private readonly defaultOnValidationError?: RouteSchema["onValidationError"]
|
|
@@ -1262,6 +1334,10 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1262
1334
|
throw new RangeError("maxInboundDeadlineMs must be a finite positive number")
|
|
1263
1335
|
}
|
|
1264
1336
|
this.gracefulSignals = options.gracefulSignals ?? false
|
|
1337
|
+
this.capacityGate = options.admission
|
|
1338
|
+
this.onCapabilityUse = options.onCapabilityUse
|
|
1339
|
+
this.configuredIdempotencyStore = options.idempotencyStore
|
|
1340
|
+
this.lazyIdempotencyStore = undefined
|
|
1265
1341
|
this.logger = options.logger ?? jsonLogger()
|
|
1266
1342
|
this.defaultOnValidationError = options.onValidationError
|
|
1267
1343
|
this.bunServer = undefined
|
|
@@ -1713,9 +1789,35 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1713
1789
|
schema: RouteSchema | undefined,
|
|
1714
1790
|
handler: (context: never) => unknown,
|
|
1715
1791
|
): void {
|
|
1792
|
+
const capabilities = normalizeRouteCapabilities(schema?.capabilities)
|
|
1793
|
+
const handlerAssurance = assuranceDeclarationsOf(handler as unknown as object)
|
|
1794
|
+
const invalidHandlerScope = handlerAssurance.find(
|
|
1795
|
+
(declaration) => declaration.scope !== "plugin",
|
|
1796
|
+
)
|
|
1797
|
+
if (invalidHandlerScope !== undefined) {
|
|
1798
|
+
throw new RouteConfigError(
|
|
1799
|
+
"INVALID_ASSURANCE",
|
|
1800
|
+
`route handler assurance must use plugin scope (received ${invalidHandlerScope.scope})`,
|
|
1801
|
+
)
|
|
1802
|
+
}
|
|
1803
|
+
const routeDecorations: Record<PropertyKey, unknown> = { ...this.decorations }
|
|
1804
|
+
if (capabilities.length > 0) {
|
|
1805
|
+
routeDecorations[CAPABILITY_GUARD] = createCapabilityGuard(
|
|
1806
|
+
capabilities,
|
|
1807
|
+
method,
|
|
1808
|
+
path,
|
|
1809
|
+
this.onCapabilityUse,
|
|
1810
|
+
)
|
|
1811
|
+
}
|
|
1812
|
+
const hasDecorations = Reflect.ownKeys(routeDecorations).length > 0
|
|
1813
|
+
// An idempotency route runs a dedupe lane that must buffer the body and capture the response, so it
|
|
1814
|
+
// never takes the fused/native fast path — force it onto the portable matched lane (which routes
|
|
1815
|
+
// through `fetchMatched`, where the dedupe wrapper lives).
|
|
1816
|
+
const idempotent = this.resolveIdempotency(schema)
|
|
1716
1817
|
const bare =
|
|
1717
1818
|
schema?.body === undefined &&
|
|
1718
1819
|
schema?.query === undefined &&
|
|
1820
|
+
idempotent === undefined &&
|
|
1719
1821
|
this.derives.length === 0 &&
|
|
1720
1822
|
this.beforeHandleHooks.length === 0 &&
|
|
1721
1823
|
this.afterHandleHooks.length === 0 &&
|
|
@@ -1724,7 +1826,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1724
1826
|
bare && this.aroundHooks.length === 0
|
|
1725
1827
|
? this.buildFusedWeb(
|
|
1726
1828
|
handler as unknown as InternalHandler,
|
|
1727
|
-
|
|
1829
|
+
hasDecorations ? routeDecorations : undefined,
|
|
1728
1830
|
isContextlessNoArgArrow(handler),
|
|
1729
1831
|
)
|
|
1730
1832
|
: undefined
|
|
@@ -1733,10 +1835,11 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1733
1835
|
// with the concrete RawContext the typed handler expects, so this is sound.
|
|
1734
1836
|
handler: handler as unknown as InternalHandler,
|
|
1735
1837
|
schema,
|
|
1838
|
+
idempotent,
|
|
1736
1839
|
fusedWeb,
|
|
1737
1840
|
derives: [...this.derives],
|
|
1738
|
-
decorations:
|
|
1739
|
-
hasDecorations
|
|
1841
|
+
decorations: routeDecorations,
|
|
1842
|
+
hasDecorations,
|
|
1740
1843
|
beforeHandle: [...this.beforeHandleHooks],
|
|
1741
1844
|
afterHandle: [...this.afterHandleHooks],
|
|
1742
1845
|
onError: [...this.onErrorHooks],
|
|
@@ -1763,9 +1866,14 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1763
1866
|
this.onErrorHooks.length === 0,
|
|
1764
1867
|
}
|
|
1765
1868
|
this.router.add(method, path, entry)
|
|
1766
|
-
const descriptor: RouteDescriptor = {
|
|
1869
|
+
const descriptor: RouteDescriptor = {
|
|
1870
|
+
method,
|
|
1871
|
+
path,
|
|
1872
|
+
schema,
|
|
1873
|
+
...(capabilities.length > 0 ? { capabilities } : {}),
|
|
1874
|
+
}
|
|
1767
1875
|
this.routeList.push(descriptor)
|
|
1768
|
-
const routeAssurance: AssuranceDeclaration[] = [...this.activeAssurance]
|
|
1876
|
+
const routeAssurance: AssuranceDeclaration[] = [...this.activeAssurance, ...handlerAssurance]
|
|
1769
1877
|
if (schema?.body !== undefined) {
|
|
1770
1878
|
routeAssurance.push(
|
|
1771
1879
|
Object.freeze({
|
|
@@ -1775,6 +1883,27 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1775
1883
|
}),
|
|
1776
1884
|
)
|
|
1777
1885
|
}
|
|
1886
|
+
// Declaring `schema.idempotency` is the evidence that satisfies a write capability's idempotency
|
|
1887
|
+
// requirement (capability assurance reads these ids). `durable` scope additionally proves the
|
|
1888
|
+
// durable-command requirement.
|
|
1889
|
+
if (schema?.idempotency !== undefined) {
|
|
1890
|
+
routeAssurance.push(
|
|
1891
|
+
Object.freeze({
|
|
1892
|
+
id: NIFRA_ASSURANCE_IDS.IDEMPOTENCY_KEY,
|
|
1893
|
+
source: "route-schema",
|
|
1894
|
+
scope: "plugin",
|
|
1895
|
+
}),
|
|
1896
|
+
)
|
|
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
|
+
}
|
|
1778
1907
|
this.rawEntries.push({
|
|
1779
1908
|
method,
|
|
1780
1909
|
path,
|
|
@@ -1784,6 +1913,24 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1784
1913
|
})
|
|
1785
1914
|
}
|
|
1786
1915
|
|
|
1916
|
+
/** Resolve a route's declared idempotency into a pinned store + defaults, or `undefined` when off. */
|
|
1917
|
+
private resolveIdempotency(schema: RouteSchema | undefined): ResolvedIdempotency | undefined {
|
|
1918
|
+
const config = schema?.idempotency
|
|
1919
|
+
if (config === undefined) return undefined
|
|
1920
|
+
return Object.freeze({
|
|
1921
|
+
store: config.store ?? this.defaultIdempotencyStore(),
|
|
1922
|
+
ttlMs: config.ttlMs ?? DEFAULT_IDEMPOTENCY_TTL_MS,
|
|
1923
|
+
headerName: (config.headerName ?? DEFAULT_IDEMPOTENCY_HEADER).toLowerCase(),
|
|
1924
|
+
})
|
|
1925
|
+
}
|
|
1926
|
+
|
|
1927
|
+
/** The app-wide idempotency store: the configured one, else a lazily-created shared in-process store. */
|
|
1928
|
+
private defaultIdempotencyStore(): IdempotencyStore {
|
|
1929
|
+
if (this.configuredIdempotencyStore !== undefined) return this.configuredIdempotencyStore
|
|
1930
|
+
this.lazyIdempotencyStore ??= new MemoryIdempotencyStore()
|
|
1931
|
+
return this.lazyIdempotencyStore
|
|
1932
|
+
}
|
|
1933
|
+
|
|
1787
1934
|
/**
|
|
1788
1935
|
* Merge another server's routes into this one — the composition escape hatch for large apps.
|
|
1789
1936
|
*
|
|
@@ -1855,6 +2002,15 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1855
2002
|
private fetchSource(
|
|
1856
2003
|
source: RequestSource,
|
|
1857
2004
|
platform?: Platform<EnvOf<Ctx>>,
|
|
2005
|
+
): MaybePromise<Response> {
|
|
2006
|
+
// Off path (default): straight through — one property check, no closure, no promise.
|
|
2007
|
+
if (this.capacityGate === undefined) return this.fetchSourceInner(source, platform)
|
|
2008
|
+
return this.admitGated(requestOf(source), () => this.fetchSourceInner(source, platform))
|
|
2009
|
+
}
|
|
2010
|
+
|
|
2011
|
+
private fetchSourceInner(
|
|
2012
|
+
source: RequestSource,
|
|
2013
|
+
platform?: Platform<EnvOf<Ctx>>,
|
|
1858
2014
|
): MaybePromise<Response> {
|
|
1859
2015
|
// Non-`async` on purpose: `dispatch` may return a `Response` *synchronously* (the bare-route fast
|
|
1860
2016
|
// path, see RouteEntry.bare), and an `async fetch` would wrap every such result in a redundant
|
|
@@ -1886,6 +2042,15 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1886
2042
|
source: RequestSource,
|
|
1887
2043
|
entry: RouteEntry,
|
|
1888
2044
|
params: Record<string, string>,
|
|
2045
|
+
): MaybePromise<Response> {
|
|
2046
|
+
if (this.capacityGate === undefined) return this.fetchMatchedInner(source, entry, params)
|
|
2047
|
+
return this.admitGated(requestOf(source), () => this.fetchMatchedInner(source, entry, params))
|
|
2048
|
+
}
|
|
2049
|
+
|
|
2050
|
+
private fetchMatchedInner(
|
|
2051
|
+
source: RequestSource,
|
|
2052
|
+
entry: RouteEntry,
|
|
2053
|
+
params: Record<string, string>,
|
|
1889
2054
|
): MaybePromise<Response> {
|
|
1890
2055
|
const outcome = this.runMatched(
|
|
1891
2056
|
source,
|
|
@@ -1906,6 +2071,45 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1906
2071
|
: this.applyOnResponseAndFinalize(outcome, requestOf(source))
|
|
1907
2072
|
}
|
|
1908
2073
|
|
|
2074
|
+
/**
|
|
2075
|
+
* Run `produce` under the capacity gate: admit → run → release exactly once when the response is
|
|
2076
|
+
* produced (or the run throws). Only reached when {@link capacityGate} is set, so the off path pays
|
|
2077
|
+
* nothing. The slot is held for the duration of handler execution, not the streaming of the body —
|
|
2078
|
+
* capacity here bounds concurrent *work*, matching how in-flight is counted.
|
|
2079
|
+
*/
|
|
2080
|
+
private admitGated(req: Request, produce: () => MaybePromise<Response>): MaybePromise<Response> {
|
|
2081
|
+
const decision = (this.capacityGate as AdmissionController).admit(req)
|
|
2082
|
+
return decision instanceof Promise
|
|
2083
|
+
? decision.then((settled) => this.runAdmitted(settled, produce))
|
|
2084
|
+
: this.runAdmitted(decision, produce)
|
|
2085
|
+
}
|
|
2086
|
+
|
|
2087
|
+
private runAdmitted(
|
|
2088
|
+
decision: AdmissionDecision,
|
|
2089
|
+
produce: () => MaybePromise<Response>,
|
|
2090
|
+
): MaybePromise<Response> {
|
|
2091
|
+
if (!decision.admitted) return decision.response // shed: ready 429, no slot held
|
|
2092
|
+
let released = false
|
|
2093
|
+
const release = (): void => {
|
|
2094
|
+
if (released) return
|
|
2095
|
+
released = true
|
|
2096
|
+
decision.release()
|
|
2097
|
+
}
|
|
2098
|
+
let outcome: MaybePromise<Response>
|
|
2099
|
+
try {
|
|
2100
|
+
outcome = produce()
|
|
2101
|
+
} catch (error) {
|
|
2102
|
+
release()
|
|
2103
|
+
throw error
|
|
2104
|
+
}
|
|
2105
|
+
// Release the slot once the response settles — on resolve OR reject — via `finally`, which passes
|
|
2106
|
+
// the value/rejection through unchanged. (A single settle hook, rather than separate then-arms: the
|
|
2107
|
+
// request pipeline resolves handler errors to a Response, so a rejection arm would be unreachable.)
|
|
2108
|
+
if (outcome instanceof Promise) return outcome.finally(release)
|
|
2109
|
+
release()
|
|
2110
|
+
return outcome
|
|
2111
|
+
}
|
|
2112
|
+
|
|
1909
2113
|
/**
|
|
1910
2114
|
* Like {@link fetch}, but renders a plain-data result **without** building a Web `Response` — the
|
|
1911
2115
|
* `@nifrajs/node` adapter serializes the returned primitives straight to the socket, skipping the undici
|
|
@@ -1923,8 +2127,13 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1923
2127
|
source: RequestSource,
|
|
1924
2128
|
platform?: Platform<EnvOf<Ctx>>,
|
|
1925
2129
|
): MaybePromise<NodeServeOutcome> {
|
|
1926
|
-
// onResponse hooks transform a Response,
|
|
1927
|
-
|
|
2130
|
+
// onResponse hooks transform a Response, and the capacity gate wraps the Web response path — both
|
|
2131
|
+
// force the Web path here (the gated `fetchSource` admits/sheds/releases); wrap its result.
|
|
2132
|
+
if (
|
|
2133
|
+
this.onResponseHooks.length > 0 ||
|
|
2134
|
+
this.onResponseFinalizedHooks.length > 0 ||
|
|
2135
|
+
this.capacityGate !== undefined
|
|
2136
|
+
) {
|
|
1928
2137
|
const response = this.fetchSource(source, platform)
|
|
1929
2138
|
return response instanceof Promise
|
|
1930
2139
|
? response.then((settled) => ({ kind: "response", response: settled }))
|
|
@@ -2196,6 +2405,111 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2196
2405
|
wrapResponse: (response: Response) => T,
|
|
2197
2406
|
onTimeout: () => T,
|
|
2198
2407
|
webFast: boolean,
|
|
2408
|
+
): MaybePromise<T> {
|
|
2409
|
+
// An idempotency route runs its dedupe lane first; on a fresh key it delegates to the normal lanes
|
|
2410
|
+
// (with the body buffered). All non-idempotent routes skip straight to the lanes — no added cost.
|
|
2411
|
+
if (entry.idempotent !== undefined) {
|
|
2412
|
+
return this.runIdempotent(source, platform, entry, params, search, wrapResponse)
|
|
2413
|
+
}
|
|
2414
|
+
return this.runMatchedLanes(
|
|
2415
|
+
source,
|
|
2416
|
+
platform,
|
|
2417
|
+
entry,
|
|
2418
|
+
params,
|
|
2419
|
+
search,
|
|
2420
|
+
finalize,
|
|
2421
|
+
wrapResponse,
|
|
2422
|
+
onTimeout,
|
|
2423
|
+
webFast,
|
|
2424
|
+
)
|
|
2425
|
+
}
|
|
2426
|
+
|
|
2427
|
+
/**
|
|
2428
|
+
* Idempotency dedupe lane. Reads the `Idempotency-Key`, fingerprints the request, and consults the
|
|
2429
|
+
* store: a fresh key runs the handler (via {@link runMatchedLanes} with the body buffered) and stores
|
|
2430
|
+
* the response; a repeat key replays it without re-running; a key reused with a different body or an
|
|
2431
|
+
* in-flight duplicate is rejected. Every branch materializes a `Response`, then adapts to `T` via the
|
|
2432
|
+
* caller's `wrapResponse`, so it works on the Web fetch path and the Node-native outcome path alike.
|
|
2433
|
+
*/
|
|
2434
|
+
private async runIdempotent<T>(
|
|
2435
|
+
source: RequestSource,
|
|
2436
|
+
platform: Platform | undefined,
|
|
2437
|
+
entry: RouteEntry,
|
|
2438
|
+
params: Record<string, string>,
|
|
2439
|
+
search: string | undefined,
|
|
2440
|
+
wrapResponse: (response: Response) => T,
|
|
2441
|
+
): Promise<T> {
|
|
2442
|
+
const config = entry.idempotent as ResolvedIdempotency
|
|
2443
|
+
const req = requestOf(source)
|
|
2444
|
+
const key = req.headers.get(config.headerName)
|
|
2445
|
+
if (key === null || !validIdempotencyKey(key)) {
|
|
2446
|
+
return wrapResponse(jsonError(400, "idempotency_key_required"))
|
|
2447
|
+
}
|
|
2448
|
+
const read = await readBoundedBytes(req, this.maxBodyBytes)
|
|
2449
|
+
if (!read.ok) {
|
|
2450
|
+
return wrapResponse(
|
|
2451
|
+
jsonError(read.status, read.status === 413 ? "payload_too_large" : "bad_request"),
|
|
2452
|
+
)
|
|
2453
|
+
}
|
|
2454
|
+
const url = urlPartsOf(req.url)
|
|
2455
|
+
const fingerprint = await computeIdempotencyFingerprint(
|
|
2456
|
+
req.method,
|
|
2457
|
+
`${url.pathname}${url.search}`,
|
|
2458
|
+
read.bytes,
|
|
2459
|
+
)
|
|
2460
|
+
|
|
2461
|
+
const begin = await config.store.begin(key, fingerprint, config.ttlMs)
|
|
2462
|
+
if (begin.state === "replay") return wrapResponse(responseFromStored(begin.response))
|
|
2463
|
+
if (begin.state === "mismatch") return wrapResponse(jsonError(409, "idempotency_key_reused"))
|
|
2464
|
+
if (begin.state === "in-flight") {
|
|
2465
|
+
return wrapResponse(jsonError(409, "idempotency_in_progress", { "Retry-After": "1" }))
|
|
2466
|
+
}
|
|
2467
|
+
|
|
2468
|
+
// Fresh key: re-expose the buffered body as a Request so the normal lanes can read + validate it,
|
|
2469
|
+
// then run those lanes to a concrete Response.
|
|
2470
|
+
const bufferedInit: RequestInit = { method: req.method, headers: req.headers }
|
|
2471
|
+
if (read.bytes.byteLength > 0)
|
|
2472
|
+
bufferedInit.body = read.bytes as NonNullable<RequestInit["body"]>
|
|
2473
|
+
const buffered: RequestSource = new Request(req.url, bufferedInit)
|
|
2474
|
+
let response: Response
|
|
2475
|
+
try {
|
|
2476
|
+
response = await this.runMatchedLanes(
|
|
2477
|
+
buffered,
|
|
2478
|
+
platform,
|
|
2479
|
+
entry,
|
|
2480
|
+
params,
|
|
2481
|
+
search,
|
|
2482
|
+
toResponse,
|
|
2483
|
+
IDENTITY_RESPONSE,
|
|
2484
|
+
RESPONSE_TIMEOUT,
|
|
2485
|
+
false,
|
|
2486
|
+
)
|
|
2487
|
+
} catch (err) {
|
|
2488
|
+
// The lanes never throw (errors resolve to a 500 Response), but stay fail-safe: release the
|
|
2489
|
+
// reservation so a retry isn't wedged, then re-throw.
|
|
2490
|
+
await config.store.abandon(key)
|
|
2491
|
+
throw err
|
|
2492
|
+
}
|
|
2493
|
+
// Cache only successful responses; release the key on any error so the client can retry.
|
|
2494
|
+
if (response.status >= 200 && response.status < 300) {
|
|
2495
|
+
await config.store.complete(key, await serializeResponse(response))
|
|
2496
|
+
} else {
|
|
2497
|
+
await config.store.abandon(key)
|
|
2498
|
+
}
|
|
2499
|
+
return wrapResponse(response)
|
|
2500
|
+
}
|
|
2501
|
+
|
|
2502
|
+
/** Run a matched route through its selected execution lane (fused/bare/bodyOnly/queryOnly/lifecycle). */
|
|
2503
|
+
private runMatchedLanes<T>(
|
|
2504
|
+
source: RequestSource,
|
|
2505
|
+
platform: Platform | undefined,
|
|
2506
|
+
entry: RouteEntry,
|
|
2507
|
+
params: Record<string, string>,
|
|
2508
|
+
search: string | undefined,
|
|
2509
|
+
finalize: (result: unknown, set: CtxSet) => T,
|
|
2510
|
+
wrapResponse: (response: Response) => T,
|
|
2511
|
+
onTimeout: () => T,
|
|
2512
|
+
webFast: boolean,
|
|
2199
2513
|
): MaybePromise<T> {
|
|
2200
2514
|
// Translate the absolute wire deadline once, clamp it to local policy, then use the resulting
|
|
2201
2515
|
// duration for both c.signal and c.budget. A client can only shorten work, never extend it.
|
|
@@ -2426,7 +2740,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2426
2740
|
*/
|
|
2427
2741
|
private buildFusedWeb(
|
|
2428
2742
|
handler: InternalHandler,
|
|
2429
|
-
decorations: Record<
|
|
2743
|
+
decorations: Record<PropertyKey, unknown> | undefined,
|
|
2430
2744
|
contextless: boolean,
|
|
2431
2745
|
): FusedWebRunner {
|
|
2432
2746
|
const maxBodyBytes = this.maxBodyBytes
|
|
@@ -3097,7 +3411,10 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
3097
3411
|
const mayUseFusedNative =
|
|
3098
3412
|
this.requestTimeoutMs === 0 &&
|
|
3099
3413
|
this.onResponseHooks.length === 0 &&
|
|
3100
|
-
this.onResponseFinalizedHooks.length === 0
|
|
3414
|
+
this.onResponseFinalizedHooks.length === 0 &&
|
|
3415
|
+
// The capacity gate must wrap every request; the fused lane bypasses fetchMatched, so enabling
|
|
3416
|
+
// admission drops fusion (native matching stays) and routes through the gated matched lane.
|
|
3417
|
+
this.capacityGate === undefined
|
|
3101
3418
|
const unboundedSignal = mayUseFusedNative ? getNeverAbortSignal() : undefined
|
|
3102
3419
|
const unboundedBudget = mayUseFusedNative ? getUnboundedRequestBudget() : undefined
|
|
3103
3420
|
let count = 0
|