@nifrajs/core 1.9.1 → 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 +4 -1
- 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 +33 -0
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +139 -15
- 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 +4 -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 +233 -20
package/src/index.ts
CHANGED
|
@@ -12,13 +12,16 @@
|
|
|
12
12
|
* (it shipped at "0.0.0" through 1.0.0 before those guards existed). Kept narrow (`as const`) so
|
|
13
13
|
* consumers can pin it at the type level — see `test/version.test-d.ts`.
|
|
14
14
|
*/
|
|
15
|
-
export const VERSION = "1.
|
|
15
|
+
export const VERSION = "1.10.0" as const
|
|
16
16
|
|
|
17
17
|
export type Version = typeof VERSION
|
|
18
18
|
|
|
19
19
|
export * from "./assurance.ts"
|
|
20
|
+
export * from "./capabilities.ts"
|
|
21
|
+
export * from "./classification.ts"
|
|
20
22
|
export * from "./diff.ts"
|
|
21
23
|
export { FrameworkError, RouteConfigError, type RouteConfigErrorCode } from "./errors.ts"
|
|
24
|
+
export * from "./idempotency.ts"
|
|
22
25
|
export { FRAMEWORK_NAME, type FrameworkName } from "./internal/brand.ts"
|
|
23
26
|
export * from "./reflection.ts"
|
|
24
27
|
export { METHODS, type Method, Router, type RouterMatch } from "./router/router.ts"
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
export const CAPABILITY_GUARD = Symbol("nifra.capability.guard")
|
|
2
|
+
const CAPABILITY_ID = /^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*$/
|
|
3
|
+
|
|
4
|
+
export function validCapabilityId(value: string): boolean {
|
|
5
|
+
return CAPABILITY_ID.test(value)
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface CapabilityUseEvent {
|
|
9
|
+
readonly capability: string
|
|
10
|
+
readonly method: string
|
|
11
|
+
readonly path: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface CapabilityGuard {
|
|
15
|
+
readonly allowed: readonly string[]
|
|
16
|
+
readonly method: string
|
|
17
|
+
readonly path: string
|
|
18
|
+
readonly onUse?: (event: CapabilityUseEvent) => void
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function normalizeRouteCapabilities(
|
|
22
|
+
values: readonly string[] | undefined,
|
|
23
|
+
): readonly string[] {
|
|
24
|
+
if (values === undefined) return Object.freeze([])
|
|
25
|
+
if (!Array.isArray(values)) throw new TypeError("route capabilities must be an array")
|
|
26
|
+
const out: string[] = []
|
|
27
|
+
const seen = new Set<string>()
|
|
28
|
+
for (const value of values) {
|
|
29
|
+
if (typeof value !== "string" || !validCapabilityId(value)) {
|
|
30
|
+
throw new Error(
|
|
31
|
+
`route capabilities: invalid capability id ${JSON.stringify(value)} (use lowercase dot/dash segments)`,
|
|
32
|
+
)
|
|
33
|
+
}
|
|
34
|
+
if (!seen.has(value)) {
|
|
35
|
+
seen.add(value)
|
|
36
|
+
out.push(value)
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return Object.freeze(out)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function createCapabilityGuard(
|
|
43
|
+
allowed: readonly string[],
|
|
44
|
+
method: string,
|
|
45
|
+
path: string,
|
|
46
|
+
onUse: ((event: CapabilityUseEvent) => void) | undefined,
|
|
47
|
+
): CapabilityGuard {
|
|
48
|
+
return Object.freeze({ allowed, method, path, ...(onUse !== undefined ? { onUse } : {}) })
|
|
49
|
+
}
|
|
@@ -26,6 +26,7 @@ export const NIFRA_ASSURANCE_IDS = Object.freeze({
|
|
|
26
26
|
AUTHENTICATED: "nifra.authenticated",
|
|
27
27
|
BODY_BOUNDED: "nifra.body-bounded",
|
|
28
28
|
CSRF: "nifra.csrf",
|
|
29
|
+
DURABLE_COMMAND: "nifra.durable-command",
|
|
29
30
|
IDEMPOTENCY_KEY: "nifra.idempotency-key",
|
|
30
31
|
IP_RESTRICTED: "nifra.ip-restricted",
|
|
31
32
|
RATE_LIMITED: "nifra.rate-limited",
|
package/src/reflection.ts
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
* recognizes Nifra/TypeBox carriers and raw JSON Schema without depending on a validator package.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
+
import { type DataClassification, isDataClassification } from "./classification.ts"
|
|
11
|
+
import { normalizeRouteCapabilities } from "./internal/capability-runtime.ts"
|
|
10
12
|
import { type AssuranceEvidence, validEvidence } from "./internal/route-assurance.ts"
|
|
11
13
|
import type { StandardSchemaV1 } from "./schema/standard.ts"
|
|
12
14
|
import type { ToolAnnotations } from "./server/server.ts"
|
|
@@ -45,6 +47,9 @@ export interface ReflectedRoute {
|
|
|
45
47
|
readonly path: string
|
|
46
48
|
readonly schema?: ReflectedRouteSchema
|
|
47
49
|
readonly assurance?: readonly AssuranceEvidence[]
|
|
50
|
+
readonly capabilities?: readonly string[]
|
|
51
|
+
/** Highest data-sensitivity the response carries, when declared. */
|
|
52
|
+
readonly classification?: DataClassification
|
|
48
53
|
readonly tool?: {
|
|
49
54
|
readonly name: string
|
|
50
55
|
readonly description: string
|
|
@@ -196,11 +201,20 @@ export function reflectRoutes(source: unknown): readonly ReflectedRoute[] {
|
|
|
196
201
|
const schema = reflectedRouteSchema(route.schema)
|
|
197
202
|
const tool = reflectedTool(route.tool)
|
|
198
203
|
const assurance = reflectedAssurance(route.assurance)
|
|
204
|
+
const capabilities = normalizeRouteCapabilities(
|
|
205
|
+
Array.isArray(route.capabilities) ? (route.capabilities as readonly string[]) : undefined,
|
|
206
|
+
)
|
|
207
|
+
const rawClassification = recordOf(route.schema)?.classification
|
|
208
|
+
const classification: DataClassification | undefined = isDataClassification(rawClassification)
|
|
209
|
+
? rawClassification
|
|
210
|
+
: undefined
|
|
199
211
|
reflected.push({
|
|
200
212
|
method: route.method.toUpperCase(),
|
|
201
213
|
path: route.path,
|
|
202
214
|
...(schema !== undefined ? { schema } : {}),
|
|
203
215
|
...(assurance !== undefined ? { assurance } : {}),
|
|
216
|
+
...(capabilities.length > 0 ? { capabilities } : {}),
|
|
217
|
+
...(classification !== undefined ? { classification } : {}),
|
|
204
218
|
...(tool !== undefined ? { tool } : {}),
|
|
205
219
|
})
|
|
206
220
|
}
|
package/src/server/context.ts
CHANGED
|
@@ -1,7 +1,25 @@
|
|
|
1
|
-
import type { RequestBudget } from "
|
|
1
|
+
import type { RequestBudget } from "../budget.ts"
|
|
2
|
+
import type { DataClassification } from "../classification.ts"
|
|
3
|
+
import type { IdempotencyScope, IdempotencyStore } from "../idempotency.ts"
|
|
2
4
|
import type { InferOutput, StandardIssue, StandardSchemaV1 } from "../schema/standard.ts"
|
|
3
5
|
import type { CookieOptions } from "./cookies.ts"
|
|
4
6
|
|
|
7
|
+
/**
|
|
8
|
+
* Declares a mutating route as idempotent: the server dedupes retries on an `Idempotency-Key` header
|
|
9
|
+
* (see {@link RouteSchema.idempotency}). Satisfies the capability-assurance idempotency requirement
|
|
10
|
+
* for a `write` capability — `scope: "durable"` additionally clears the durable-command requirement.
|
|
11
|
+
*/
|
|
12
|
+
export interface IdempotencyConfig {
|
|
13
|
+
/** `request` = in-process dedupe; `durable` = cross-restart, backed by a durable store. */
|
|
14
|
+
readonly scope: IdempotencyScope
|
|
15
|
+
/** Retention for the stored response. Default 24h. */
|
|
16
|
+
readonly ttlMs?: number
|
|
17
|
+
/** Store override. Defaults to the server's shared in-memory store; inject a durable store here. */
|
|
18
|
+
readonly store?: IdempotencyStore
|
|
19
|
+
/** Header carrying the key. Default `idempotency-key`. */
|
|
20
|
+
readonly headerName?: string
|
|
21
|
+
}
|
|
22
|
+
|
|
5
23
|
/** Flattens an intersection into a single object type for readable hovers. */
|
|
6
24
|
export type Prettify<T> = { [K in keyof T]: T[K] } & {}
|
|
7
25
|
|
|
@@ -25,6 +43,22 @@ export type Params<Path extends string> = Prettify<RawParams<Path>>
|
|
|
25
43
|
|
|
26
44
|
/** Per-route input schemas. Each is any Standard Schema (zod/valibot/arktype/…). */
|
|
27
45
|
export interface RouteSchema {
|
|
46
|
+
/** Declared effect tokens. Reflected for assurance; never read by validation or serialization. */
|
|
47
|
+
readonly capabilities?: readonly string[]
|
|
48
|
+
/**
|
|
49
|
+
* Dedupe retries of this (mutating) route on an `Idempotency-Key` header. The first request runs and
|
|
50
|
+
* its response is stored; a retry with the same key replays that response without re-running the
|
|
51
|
+
* handler, and a key reused with a different body is rejected (409). A missing key on an
|
|
52
|
+
* idempotency-required route fails closed (400). Off the hot path — routes without it are unchanged.
|
|
53
|
+
*/
|
|
54
|
+
readonly idempotency?: IdempotencyConfig
|
|
55
|
+
/**
|
|
56
|
+
* Highest data-sensitivity this route's response body carries (`public` | `pii` | `secret`). A
|
|
57
|
+
* declarative, compile-time + introspection fact — never validated at runtime. Reflected for tooling,
|
|
58
|
+
* recorded in the capability lockfile (so a route that starts returning PII is a reviewable change),
|
|
59
|
+
* and read by downstream policy (e.g. a partner-API surface refusing to expose `pii`/`secret`).
|
|
60
|
+
*/
|
|
61
|
+
readonly classification?: DataClassification
|
|
28
62
|
readonly body?: StandardSchemaV1
|
|
29
63
|
readonly query?: StandardSchemaV1
|
|
30
64
|
/** Optional **response contract**. When declared: the handler's return is type-checked against it
|
package/src/server/contract.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
import type { DataClassification } from "../classification.ts"
|
|
1
2
|
import { RouteConfigError } from "../errors.ts"
|
|
3
|
+
import { normalizeRouteCapabilities } from "../internal/capability-runtime.ts"
|
|
2
4
|
import { METHODS, type Method } from "../router/router.ts"
|
|
3
5
|
import type { InferOutput, StandardSchemaV1 } from "../schema/standard.ts"
|
|
4
|
-
import type { Context, Params, RouteSchema } from "./context.ts"
|
|
6
|
+
import type { Context, IdempotencyConfig, Params, RouteSchema } from "./context.ts"
|
|
5
7
|
import type { OutputOf } from "./registry.ts"
|
|
6
8
|
import { Server } from "./server.ts"
|
|
7
9
|
|
|
@@ -27,6 +29,12 @@ export interface OperationDef {
|
|
|
27
29
|
readonly body?: StandardSchemaV1
|
|
28
30
|
readonly query?: StandardSchemaV1
|
|
29
31
|
readonly response?: StandardSchemaV1
|
|
32
|
+
/** Declared effect tokens, carried into route reflection and capability assurance. */
|
|
33
|
+
readonly capabilities?: readonly string[]
|
|
34
|
+
/** Dedupe retries of this operation on an `Idempotency-Key` header (see {@link RouteSchema.idempotency}). */
|
|
35
|
+
readonly idempotency?: IdempotencyConfig
|
|
36
|
+
/** Highest data-sensitivity the response carries (see {@link RouteSchema.classification}). */
|
|
37
|
+
readonly classification?: DataClassification
|
|
30
38
|
/** Short summary (OpenAPI `summary`). */
|
|
31
39
|
readonly summary?: string
|
|
32
40
|
/** Longer description (OpenAPI `description`, CommonMark). */
|
|
@@ -167,6 +175,7 @@ export function defineContract<const C extends ContractShape>(contract: C): C {
|
|
|
167
175
|
if (seen.has(key)) {
|
|
168
176
|
throw new RouteConfigError("DUPLICATE_ROUTE", `duplicate operation route: ${key}`)
|
|
169
177
|
}
|
|
178
|
+
normalizeRouteCapabilities(op.capabilities)
|
|
170
179
|
seen.add(key)
|
|
171
180
|
}
|
|
172
181
|
return contract
|
|
@@ -219,11 +228,19 @@ export function implement<const C extends ContractShape, H extends HandlersFor<C
|
|
|
219
228
|
// once here at bind time, not per request — it just carries a reference to the op's existing schema.
|
|
220
229
|
// A response-less op still yields `undefined`, byte-identical to before.
|
|
221
230
|
const schema: RouteSchema | undefined =
|
|
222
|
-
op.body !== undefined ||
|
|
231
|
+
op.body !== undefined ||
|
|
232
|
+
op.query !== undefined ||
|
|
233
|
+
op.response !== undefined ||
|
|
234
|
+
op.capabilities !== undefined ||
|
|
235
|
+
op.idempotency !== undefined ||
|
|
236
|
+
op.classification !== undefined
|
|
223
237
|
? {
|
|
224
238
|
...(op.body !== undefined ? { body: op.body } : {}),
|
|
225
239
|
...(op.query !== undefined ? { query: op.query } : {}),
|
|
226
240
|
...(op.response !== undefined ? { response: op.response } : {}),
|
|
241
|
+
...(op.capabilities !== undefined ? { capabilities: op.capabilities } : {}),
|
|
242
|
+
...(op.idempotency !== undefined ? { idempotency: op.idempotency } : {}),
|
|
243
|
+
...(op.classification !== undefined ? { classification: op.classification } : {}),
|
|
227
244
|
}
|
|
228
245
|
: undefined
|
|
229
246
|
app.register(
|
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
|
|
@@ -313,6 +338,17 @@ export interface ServerOptions {
|
|
|
313
338
|
* shared matched lane where the gate runs); when unset, the request path pays nothing.
|
|
314
339
|
*/
|
|
315
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
|
|
316
352
|
/** Structured logger for framework events (redacts secrets). Default: JSON to stderr. */
|
|
317
353
|
readonly logger?: Logger
|
|
318
354
|
/**
|
|
@@ -354,6 +390,8 @@ export interface RouteDescriptor {
|
|
|
354
390
|
readonly schema: RouteSchema | undefined
|
|
355
391
|
/** Effective enforcement evidence, populated only during reflection/introspection. */
|
|
356
392
|
readonly assurance?: readonly AssuranceEvidence[]
|
|
393
|
+
/** Normalized declared effect tokens, populated only when the route declares any. */
|
|
394
|
+
readonly capabilities?: readonly string[]
|
|
357
395
|
readonly tool?: {
|
|
358
396
|
readonly name: string
|
|
359
397
|
readonly description: string
|
|
@@ -1233,6 +1271,10 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1233
1271
|
private readonly gracefulSignals: boolean
|
|
1234
1272
|
/** Capacity-admission gate; `undefined` = off (the request path pays nothing). */
|
|
1235
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
|
|
1236
1278
|
private readonly logger: Logger
|
|
1237
1279
|
/** App-wide validation-error fallback; a route's own `schema.onValidationError` takes precedence. */
|
|
1238
1280
|
private readonly defaultOnValidationError?: RouteSchema["onValidationError"]
|
|
@@ -1293,6 +1335,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1293
1335
|
}
|
|
1294
1336
|
this.gracefulSignals = options.gracefulSignals ?? false
|
|
1295
1337
|
this.capacityGate = options.admission
|
|
1338
|
+
this.onCapabilityUse = options.onCapabilityUse
|
|
1339
|
+
this.configuredIdempotencyStore = options.idempotencyStore
|
|
1340
|
+
this.lazyIdempotencyStore = undefined
|
|
1296
1341
|
this.logger = options.logger ?? jsonLogger()
|
|
1297
1342
|
this.defaultOnValidationError = options.onValidationError
|
|
1298
1343
|
this.bunServer = undefined
|
|
@@ -1744,9 +1789,35 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1744
1789
|
schema: RouteSchema | undefined,
|
|
1745
1790
|
handler: (context: never) => unknown,
|
|
1746
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)
|
|
1747
1817
|
const bare =
|
|
1748
1818
|
schema?.body === undefined &&
|
|
1749
1819
|
schema?.query === undefined &&
|
|
1820
|
+
idempotent === undefined &&
|
|
1750
1821
|
this.derives.length === 0 &&
|
|
1751
1822
|
this.beforeHandleHooks.length === 0 &&
|
|
1752
1823
|
this.afterHandleHooks.length === 0 &&
|
|
@@ -1755,7 +1826,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1755
1826
|
bare && this.aroundHooks.length === 0
|
|
1756
1827
|
? this.buildFusedWeb(
|
|
1757
1828
|
handler as unknown as InternalHandler,
|
|
1758
|
-
|
|
1829
|
+
hasDecorations ? routeDecorations : undefined,
|
|
1759
1830
|
isContextlessNoArgArrow(handler),
|
|
1760
1831
|
)
|
|
1761
1832
|
: undefined
|
|
@@ -1764,10 +1835,11 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1764
1835
|
// with the concrete RawContext the typed handler expects, so this is sound.
|
|
1765
1836
|
handler: handler as unknown as InternalHandler,
|
|
1766
1837
|
schema,
|
|
1838
|
+
idempotent,
|
|
1767
1839
|
fusedWeb,
|
|
1768
1840
|
derives: [...this.derives],
|
|
1769
|
-
decorations:
|
|
1770
|
-
hasDecorations
|
|
1841
|
+
decorations: routeDecorations,
|
|
1842
|
+
hasDecorations,
|
|
1771
1843
|
beforeHandle: [...this.beforeHandleHooks],
|
|
1772
1844
|
afterHandle: [...this.afterHandleHooks],
|
|
1773
1845
|
onError: [...this.onErrorHooks],
|
|
@@ -1794,9 +1866,14 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1794
1866
|
this.onErrorHooks.length === 0,
|
|
1795
1867
|
}
|
|
1796
1868
|
this.router.add(method, path, entry)
|
|
1797
|
-
const descriptor: RouteDescriptor = {
|
|
1869
|
+
const descriptor: RouteDescriptor = {
|
|
1870
|
+
method,
|
|
1871
|
+
path,
|
|
1872
|
+
schema,
|
|
1873
|
+
...(capabilities.length > 0 ? { capabilities } : {}),
|
|
1874
|
+
}
|
|
1798
1875
|
this.routeList.push(descriptor)
|
|
1799
|
-
const routeAssurance: AssuranceDeclaration[] = [...this.activeAssurance]
|
|
1876
|
+
const routeAssurance: AssuranceDeclaration[] = [...this.activeAssurance, ...handlerAssurance]
|
|
1800
1877
|
if (schema?.body !== undefined) {
|
|
1801
1878
|
routeAssurance.push(
|
|
1802
1879
|
Object.freeze({
|
|
@@ -1806,6 +1883,27 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1806
1883
|
}),
|
|
1807
1884
|
)
|
|
1808
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
|
+
}
|
|
1809
1907
|
this.rawEntries.push({
|
|
1810
1908
|
method,
|
|
1811
1909
|
path,
|
|
@@ -1815,6 +1913,24 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1815
1913
|
})
|
|
1816
1914
|
}
|
|
1817
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
|
+
|
|
1818
1934
|
/**
|
|
1819
1935
|
* Merge another server's routes into this one — the composition escape hatch for large apps.
|
|
1820
1936
|
*
|
|
@@ -1986,18 +2102,10 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1986
2102
|
release()
|
|
1987
2103
|
throw error
|
|
1988
2104
|
}
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
return response
|
|
1994
|
-
},
|
|
1995
|
-
(error) => {
|
|
1996
|
-
release()
|
|
1997
|
-
throw error
|
|
1998
|
-
},
|
|
1999
|
-
)
|
|
2000
|
-
}
|
|
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)
|
|
2001
2109
|
release()
|
|
2002
2110
|
return outcome
|
|
2003
2111
|
}
|
|
@@ -2297,6 +2405,111 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2297
2405
|
wrapResponse: (response: Response) => T,
|
|
2298
2406
|
onTimeout: () => T,
|
|
2299
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,
|
|
2300
2513
|
): MaybePromise<T> {
|
|
2301
2514
|
// Translate the absolute wire deadline once, clamp it to local policy, then use the resulting
|
|
2302
2515
|
// duration for both c.signal and c.budget. A client can only shorten work, never extend it.
|
|
@@ -2527,7 +2740,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2527
2740
|
*/
|
|
2528
2741
|
private buildFusedWeb(
|
|
2529
2742
|
handler: InternalHandler,
|
|
2530
|
-
decorations: Record<
|
|
2743
|
+
decorations: Record<PropertyKey, unknown> | undefined,
|
|
2531
2744
|
contextless: boolean,
|
|
2532
2745
|
): FusedWebRunner {
|
|
2533
2746
|
const maxBodyBytes = this.maxBodyBytes
|