@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.
Files changed (63) hide show
  1. package/dist/assurance.d.ts +5 -0
  2. package/dist/assurance.d.ts.map +1 -1
  3. package/dist/assurance.js +8 -1
  4. package/dist/assurance.js.map +1 -1
  5. package/dist/budget.d.ts +91 -0
  6. package/dist/budget.d.ts.map +1 -0
  7. package/dist/budget.js +172 -0
  8. package/dist/budget.js.map +1 -0
  9. package/dist/capabilities.d.ts +117 -0
  10. package/dist/capabilities.d.ts.map +1 -0
  11. package/dist/capabilities.js +257 -0
  12. package/dist/capabilities.js.map +1 -0
  13. package/dist/classification.d.ts +19 -0
  14. package/dist/classification.d.ts.map +1 -0
  15. package/dist/classification.js +33 -0
  16. package/dist/classification.js.map +1 -0
  17. package/dist/errors.d.ts +1 -1
  18. package/dist/errors.d.ts.map +1 -1
  19. package/dist/errors.js.map +1 -1
  20. package/dist/idempotency.d.ts +90 -0
  21. package/dist/idempotency.d.ts.map +1 -0
  22. package/dist/idempotency.js +135 -0
  23. package/dist/idempotency.js.map +1 -0
  24. package/dist/index.d.ts +5 -2
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +4 -1
  27. package/dist/index.js.map +1 -1
  28. package/dist/internal/capability-runtime.d.ts +16 -0
  29. package/dist/internal/capability-runtime.d.ts.map +1 -0
  30. package/dist/internal/capability-runtime.js +27 -0
  31. package/dist/internal/capability-runtime.js.map +1 -0
  32. package/dist/internal/route-assurance.d.ts +1 -0
  33. package/dist/internal/route-assurance.d.ts.map +1 -1
  34. package/dist/internal/route-assurance.js +1 -0
  35. package/dist/internal/route-assurance.js.map +1 -1
  36. package/dist/reflection.d.ts +4 -0
  37. package/dist/reflection.d.ts.map +1 -1
  38. package/dist/reflection.js +9 -0
  39. package/dist/reflection.js.map +1 -1
  40. package/dist/server/context.d.ts +34 -1
  41. package/dist/server/context.d.ts.map +1 -1
  42. package/dist/server/contract.d.ts +8 -1
  43. package/dist/server/contract.d.ts.map +1 -1
  44. package/dist/server/contract.js +11 -1
  45. package/dist/server/contract.js.map +1 -1
  46. package/dist/server/server.d.ts +75 -0
  47. package/dist/server/server.d.ts.map +1 -1
  48. package/dist/server/server.js +195 -9
  49. package/dist/server/server.js.map +1 -1
  50. package/package.json +11 -4
  51. package/src/assurance.ts +15 -1
  52. package/src/budget.ts +267 -0
  53. package/src/capabilities.ts +430 -0
  54. package/src/classification.ts +44 -0
  55. package/src/errors.ts +1 -0
  56. package/src/idempotency.ts +205 -0
  57. package/src/index.ts +6 -1
  58. package/src/internal/capability-runtime.ts +49 -0
  59. package/src/internal/route-assurance.ts +1 -0
  60. package/src/reflection.ts +14 -0
  61. package/src/server/context.ts +35 -1
  62. package/src/server/contract.ts +19 -2
  63. package/src/server/server.ts +328 -11
@@ -5,8 +5,24 @@ import {
5
5
  type DeadlineAdmissionOptions,
6
6
  NIFRA_DEADLINE_HEADER,
7
7
  type RequestBudget,
8
- } from "@nifrajs/budget"
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<string, unknown>
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
- Object.keys(this.decorations).length > 0 ? { ...this.decorations } : undefined,
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: { ...this.decorations },
1739
- hasDecorations: Object.keys(this.decorations).length > 0,
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 = { method, path, schema }
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, so they force the Web path; wrap its result.
1927
- if (this.onResponseHooks.length > 0 || this.onResponseFinalizedHooks.length > 0) {
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<string, unknown> | undefined,
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