@nifrajs/core 1.13.0 → 2.1.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/README.md +37 -1
- package/dist/assurance.d.ts +11 -2
- package/dist/assurance.d.ts.map +1 -1
- package/dist/assurance.js +20 -0
- package/dist/assurance.js.map +1 -1
- package/dist/capabilities.d.ts +54 -2
- package/dist/capabilities.d.ts.map +1 -1
- package/dist/capabilities.js +240 -5
- package/dist/capabilities.js.map +1 -1
- package/dist/durable-adapters.d.ts +241 -0
- package/dist/durable-adapters.d.ts.map +1 -0
- package/dist/durable-adapters.js +697 -0
- package/dist/durable-adapters.js.map +1 -0
- package/dist/durable-execution.d.ts +349 -0
- package/dist/durable-execution.d.ts.map +1 -0
- package/dist/durable-execution.js +1173 -0
- package/dist/durable-execution.js.map +1 -0
- package/dist/effect-ledger.d.ts +9 -0
- package/dist/effect-ledger.d.ts.map +1 -0
- package/dist/effect-ledger.js +8 -0
- package/dist/effect-ledger.js.map +1 -0
- package/dist/effect-lifecycle.d.ts +42 -0
- package/dist/effect-lifecycle.d.ts.map +1 -0
- package/dist/effect-lifecycle.js +66 -0
- package/dist/effect-lifecycle.js.map +1 -0
- package/dist/effect-scope.d.ts +75 -0
- package/dist/effect-scope.d.ts.map +1 -0
- package/dist/effect-scope.js +177 -0
- package/dist/effect-scope.js.map +1 -0
- package/dist/idempotency-plugin.d.ts +13 -0
- package/dist/idempotency-plugin.d.ts.map +1 -0
- package/dist/idempotency-plugin.js +16 -0
- package/dist/idempotency-plugin.js.map +1 -0
- package/dist/index.d.ts +6 -35
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -31
- package/dist/index.js.map +1 -1
- package/dist/internal/capability-runtime.d.ts +62 -1
- package/dist/internal/capability-runtime.d.ts.map +1 -1
- package/dist/internal/capability-runtime.js +11 -2
- package/dist/internal/capability-runtime.js.map +1 -1
- package/dist/internal/effect-execution.d.ts +16 -0
- package/dist/internal/effect-execution.d.ts.map +1 -0
- package/dist/internal/effect-execution.js +50 -0
- package/dist/internal/effect-execution.js.map +1 -0
- package/dist/internal/safe-equal.d.ts +20 -0
- package/dist/internal/safe-equal.d.ts.map +1 -0
- package/dist/internal/safe-equal.js +31 -0
- package/dist/internal/safe-equal.js.map +1 -0
- package/dist/ledger.d.ts +14 -6
- package/dist/ledger.d.ts.map +1 -1
- package/dist/ledger.js +31 -5
- package/dist/ledger.js.map +1 -1
- package/dist/mcp.d.ts +7 -0
- package/dist/mcp.d.ts.map +1 -0
- package/dist/mcp.js +56 -0
- package/dist/mcp.js.map +1 -0
- package/dist/node-direct.d.ts +5 -0
- package/dist/node-direct.d.ts.map +1 -0
- package/dist/node-direct.js +27 -0
- package/dist/node-direct.js.map +1 -0
- package/dist/reconciliation-worker.d.ts +108 -0
- package/dist/reconciliation-worker.d.ts.map +1 -0
- package/dist/reconciliation-worker.js +239 -0
- package/dist/reconciliation-worker.js.map +1 -0
- package/dist/reflection.d.ts +5 -0
- package/dist/reflection.d.ts.map +1 -1
- package/dist/reflection.js +2 -0
- package/dist/reflection.js.map +1 -1
- package/dist/server/client-ip.d.ts +35 -0
- package/dist/server/client-ip.d.ts.map +1 -0
- package/dist/server/client-ip.js +42 -0
- package/dist/server/client-ip.js.map +1 -0
- package/dist/server/context.d.ts +47 -2
- package/dist/server/context.d.ts.map +1 -1
- package/dist/server/contract.d.ts +14 -4
- package/dist/server/contract.d.ts.map +1 -1
- package/dist/server/contract.js +8 -2
- package/dist/server/contract.js.map +1 -1
- package/dist/server/http.d.ts +14 -0
- package/dist/server/http.d.ts.map +1 -0
- package/dist/server/http.js +45 -0
- package/dist/server/http.js.map +1 -0
- package/dist/server/idempotency-lane.d.ts +45 -0
- package/dist/server/idempotency-lane.d.ts.map +1 -0
- package/dist/server/idempotency-lane.js +205 -0
- package/dist/server/idempotency-lane.js.map +1 -0
- package/dist/server/install.d.ts +21 -0
- package/dist/server/install.d.ts.map +1 -0
- package/dist/server/install.js +21 -0
- package/dist/server/install.js.map +1 -0
- package/dist/server/ledger-lane.d.ts +53 -0
- package/dist/server/ledger-lane.d.ts.map +1 -0
- package/dist/server/ledger-lane.js +82 -0
- package/dist/server/ledger-lane.js.map +1 -0
- package/dist/server/mcp-hook.d.ts +28 -0
- package/dist/server/mcp-hook.d.ts.map +1 -0
- package/dist/server/mcp-hook.js +2 -0
- package/dist/server/mcp-hook.js.map +1 -0
- package/dist/server/node-outcome-hook.d.ts +9 -0
- package/dist/server/node-outcome-hook.d.ts.map +1 -0
- package/dist/server/node-outcome-hook.js +2 -0
- package/dist/server/node-outcome-hook.js.map +1 -0
- package/dist/server/node-outcome.d.ts +37 -0
- package/dist/server/node-outcome.d.ts.map +1 -0
- package/dist/server/node-outcome.js +75 -0
- package/dist/server/node-outcome.js.map +1 -0
- package/dist/server/plugin.d.ts +78 -0
- package/dist/server/plugin.d.ts.map +1 -0
- package/dist/server/plugin.js +57 -0
- package/dist/server/plugin.js.map +1 -0
- package/dist/server/query.d.ts +18 -0
- package/dist/server/query.d.ts.map +1 -0
- package/dist/server/query.js +102 -0
- package/dist/server/query.js.map +1 -0
- package/dist/server/registry.d.ts +31 -6
- package/dist/server/registry.d.ts.map +1 -1
- package/dist/server/request-context.d.ts +51 -0
- package/dist/server/request-context.d.ts.map +1 -0
- package/dist/server/request-context.js +184 -0
- package/dist/server/request-context.js.map +1 -0
- package/dist/server/respond.d.ts +17 -0
- package/dist/server/respond.d.ts.map +1 -0
- package/dist/server/respond.js +81 -0
- package/dist/server/respond.js.map +1 -0
- package/dist/server/runtime-core.d.ts +36 -0
- package/dist/server/runtime-core.d.ts.map +1 -0
- package/dist/server/runtime-core.js +51 -0
- package/dist/server/runtime-core.js.map +1 -0
- package/dist/server/server-types.d.ts +203 -0
- package/dist/server/server-types.d.ts.map +1 -0
- package/dist/server/server-types.js +2 -0
- package/dist/server/server-types.js.map +1 -0
- package/dist/server/server.d.ts +119 -345
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +247 -833
- package/dist/server/server.js.map +1 -1
- package/dist/server/sse-hook.d.ts +10 -0
- package/dist/server/sse-hook.d.ts.map +1 -0
- package/dist/server/sse-hook.js +2 -0
- package/dist/server/sse-hook.js.map +1 -0
- package/dist/server/sse.d.ts +7 -23
- package/dist/server/sse.d.ts.map +1 -1
- package/dist/server/sse.js +18 -0
- package/dist/server/sse.js.map +1 -1
- package/dist/server/transport-lane.d.ts +28 -0
- package/dist/server/transport-lane.d.ts.map +1 -0
- package/dist/server/transport-lane.js +106 -0
- package/dist/server/transport-lane.js.map +1 -0
- package/dist/server/websocket.d.ts +23 -1
- package/dist/server/websocket.d.ts.map +1 -1
- package/dist/server/websocket.js +10 -1
- package/dist/server/websocket.js.map +1 -1
- package/dist/server/ws-hook.d.ts +8 -11
- package/dist/server/ws-hook.d.ts.map +1 -1
- package/dist/server/ws-hook.js +7 -14
- package/dist/server/ws-hook.js.map +1 -1
- package/dist/server.d.ts +21 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +16 -0
- package/dist/server.js.map +1 -0
- package/dist/transport-codec-rich.d.ts +8 -0
- package/dist/transport-codec-rich.d.ts.map +1 -0
- package/dist/transport-codec-rich.js +14 -0
- package/dist/transport-codec-rich.js.map +1 -0
- package/dist/transport-codec.d.ts +31 -0
- package/dist/transport-codec.d.ts.map +1 -0
- package/dist/transport-codec.js +190 -0
- package/dist/transport-codec.js.map +1 -0
- package/dist/transport-plugin.d.ts +3 -0
- package/dist/transport-plugin.d.ts.map +1 -0
- package/dist/transport-plugin.js +3 -0
- package/dist/transport-plugin.js.map +1 -0
- package/dist/wire.d.ts +58 -0
- package/dist/wire.d.ts.map +1 -0
- package/dist/wire.js +445 -0
- package/dist/wire.js.map +1 -0
- package/dist/ws.d.ts +16 -7
- package/dist/ws.d.ts.map +1 -1
- package/dist/ws.js +25 -10
- package/dist/ws.js.map +1 -1
- package/package.json +122 -6
- package/src/assurance.ts +36 -1
- package/src/capabilities.ts +306 -5
- package/src/durable-adapters.ts +1039 -0
- package/src/durable-execution.ts +1676 -0
- package/src/effect-ledger.ts +9 -0
- package/src/effect-lifecycle.ts +120 -0
- package/src/effect-scope.ts +267 -0
- package/src/idempotency-plugin.ts +17 -0
- package/src/index.ts +6 -133
- package/src/internal/capability-runtime.ts +87 -1
- package/src/internal/effect-execution.ts +70 -0
- package/src/internal/safe-equal.ts +27 -0
- package/src/ledger.ts +45 -11
- package/src/mcp.ts +65 -0
- package/src/node-direct.ts +37 -0
- package/src/reconciliation-worker.ts +372 -0
- package/src/reflection.ts +7 -0
- package/src/server/client-ip.ts +60 -0
- package/src/server/context.ts +50 -2
- package/src/server/contract.ts +26 -7
- package/src/server/http.ts +58 -0
- package/src/server/idempotency-lane.ts +321 -0
- package/src/server/install.ts +28 -0
- package/src/server/ledger-lane.ts +125 -0
- package/src/server/mcp-hook.ts +41 -0
- package/src/server/node-outcome-hook.ts +9 -0
- package/src/server/node-outcome.ts +112 -0
- package/src/server/plugin.ts +92 -0
- package/src/server/query.ts +119 -0
- package/src/server/registry.ts +36 -6
- package/src/server/request-context.ts +271 -0
- package/src/server/respond.ts +94 -0
- package/src/server/runtime-core.ts +74 -0
- package/src/server/server-types.ts +204 -0
- package/src/server/server.ts +448 -1314
- package/src/server/sse-hook.ts +14 -0
- package/src/server/sse.ts +26 -0
- package/src/server/transport-lane.ts +141 -0
- package/src/server/websocket.ts +35 -1
- package/src/server/ws-hook.ts +8 -19
- package/src/server.ts +92 -0
- package/src/transport-codec-rich.ts +20 -0
- package/src/transport-codec.ts +250 -0
- package/src/transport-plugin.ts +2 -0
- package/src/wire.ts +474 -0
- package/src/ws.ts +33 -10
- package/dist/invariants.d.ts +0 -70
- package/dist/invariants.d.ts.map +0 -1
- package/dist/invariants.js +0 -487
- package/dist/invariants.js.map +0 -1
- package/src/invariants.ts +0 -604
package/src/reflection.ts
CHANGED
|
@@ -40,6 +40,8 @@ export interface SchemaReflection {
|
|
|
40
40
|
export interface ReflectedRouteSchema {
|
|
41
41
|
readonly body?: SchemaReflection
|
|
42
42
|
readonly query?: SchemaReflection
|
|
43
|
+
/** Path-params schema — constraints (uuid format, integer min/max) declared via `params: t.object(…)`. */
|
|
44
|
+
readonly params?: SchemaReflection
|
|
43
45
|
readonly response?: SchemaReflection
|
|
44
46
|
readonly errors?: Readonly<Record<string, SchemaReflection>>
|
|
45
47
|
/** The SSE event-payload schema of a typed streaming route (`app.sse()`). */
|
|
@@ -52,6 +54,9 @@ export interface ReflectedRoute {
|
|
|
52
54
|
readonly schema?: ReflectedRouteSchema
|
|
53
55
|
readonly assurance?: readonly AssuranceEvidence[]
|
|
54
56
|
readonly capabilities?: readonly string[]
|
|
57
|
+
/** Set when the route is a declared dynamic route family (a runtime-resolved template like
|
|
58
|
+
* `/api/:slug/:resource`) - so the assurance gate treats it as one classified family, not a single route. */
|
|
59
|
+
readonly family?: boolean
|
|
55
60
|
/** Field-level response classification plus the highest sensitivity present. */
|
|
56
61
|
readonly classification?: ResponseClassification
|
|
57
62
|
readonly tool?: {
|
|
@@ -167,6 +172,7 @@ const reflectedRouteSchema = (value: unknown): ReflectedRouteSchema | undefined
|
|
|
167
172
|
return {
|
|
168
173
|
...(schema.body !== undefined ? { body: reflectSchema(schema.body) } : {}),
|
|
169
174
|
...(schema.query !== undefined ? { query: reflectSchema(schema.query) } : {}),
|
|
175
|
+
...(schema.params !== undefined ? { params: reflectSchema(schema.params) } : {}),
|
|
170
176
|
...(schema.response !== undefined ? { response: reflectSchema(schema.response) } : {}),
|
|
171
177
|
...(Object.keys(reflectedErrors).length > 0 ? { errors: reflectedErrors } : {}),
|
|
172
178
|
...(schema.sse !== undefined ? { sse: reflectSchema(schema.sse) } : {}),
|
|
@@ -218,6 +224,7 @@ export function reflectRoutes(source: unknown): readonly ReflectedRoute[] {
|
|
|
218
224
|
...(schema !== undefined ? { schema } : {}),
|
|
219
225
|
...(assurance !== undefined ? { assurance } : {}),
|
|
220
226
|
...(capabilities.length > 0 ? { capabilities } : {}),
|
|
227
|
+
...(route.family === true ? { family: true } : {}),
|
|
221
228
|
...(classification !== undefined ? { classification } : {}),
|
|
222
229
|
...(tool !== undefined ? { tool } : {}),
|
|
223
230
|
})
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deriving the caller's IP (`c.clientIp`), correctly and vendor-neutrally.
|
|
3
|
+
*
|
|
4
|
+
* The default is the raw socket peer the serving adapter observed - the only value that cannot be
|
|
5
|
+
* forged by a client, since the client never controls who the TCP connection comes from. Behind a
|
|
6
|
+
* reverse proxy or CDN the socket peer is the proxy, not the caller, so a `Forwarded`/`X-Forwarded-For`
|
|
7
|
+
* value must be consulted - but ONLY as far as you actually trust the hops in front of you, because a
|
|
8
|
+
* directly-reachable app lets any client forge those headers. The trust is therefore an explicit,
|
|
9
|
+
* opt-in declaration; with none set, `c.clientIp` is the socket peer and no header is ever believed.
|
|
10
|
+
*
|
|
11
|
+
* No vendor names live here: a Cloudflare/Fastly/nginx deployment declares its own header or hop count.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* How much of the forwarding chain to trust when deriving `c.clientIp`. Omit for the safe default
|
|
16
|
+
* (socket peer only, no header believed).
|
|
17
|
+
*
|
|
18
|
+
* - `{ trustedHops: n }` - trust `n` reverse-proxy hops in front of the app. The caller is taken from
|
|
19
|
+
* `X-Forwarded-For` counted from the right past the `n` trusted hops (the socket peer is hop 0). Use
|
|
20
|
+
* this when the app sits behind `n` proxies **you operate**. Fewer entries than trusted hops means
|
|
21
|
+
* the header is too short to trust, so `c.clientIp` is `undefined` (fail closed).
|
|
22
|
+
* - `{ header: name }` - trust a single named header's first value (e.g. a CDN that overwrites it at
|
|
23
|
+
* the edge). Only declare this when the app is reachable **exclusively** through that edge; a
|
|
24
|
+
* directly-reachable app lets any client forge the header.
|
|
25
|
+
*/
|
|
26
|
+
export type ClientIpTrust = { readonly trustedHops: number } | { readonly header: string }
|
|
27
|
+
|
|
28
|
+
function firstHeaderValue(req: Request, header: string): string | undefined {
|
|
29
|
+
const raw = req.headers.get(header)
|
|
30
|
+
if (raw === null) return undefined
|
|
31
|
+
const first = raw.split(",", 1)[0]?.trim()
|
|
32
|
+
return first === undefined || first === "" ? undefined : first
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Resolve the caller IP from the raw socket `peer` (adapter-observed), the request's forwarding
|
|
37
|
+
* headers, and the app's `trust` declaration. Pure and synchronous.
|
|
38
|
+
*/
|
|
39
|
+
export function resolveClientIp(
|
|
40
|
+
peer: string | undefined,
|
|
41
|
+
req: Request,
|
|
42
|
+
trust: ClientIpTrust | undefined,
|
|
43
|
+
): string | undefined {
|
|
44
|
+
if (trust === undefined) return peer
|
|
45
|
+
if ("header" in trust) return firstHeaderValue(req, trust.header)
|
|
46
|
+
|
|
47
|
+
// trustedHops: walk `X-Forwarded-For` (client-most first) plus the socket peer as the closest hop.
|
|
48
|
+
const xff = req.headers.get("x-forwarded-for")
|
|
49
|
+
const forwarded =
|
|
50
|
+
xff === null
|
|
51
|
+
? []
|
|
52
|
+
: xff
|
|
53
|
+
.split(",")
|
|
54
|
+
.map((entry) => entry.trim())
|
|
55
|
+
.filter((entry) => entry !== "")
|
|
56
|
+
const chain = peer !== undefined ? [...forwarded, peer] : forwarded
|
|
57
|
+
// The caller sits just left of the `trustedHops` trusted proxies at the tail of the chain.
|
|
58
|
+
const index = chain.length - 1 - trust.trustedHops
|
|
59
|
+
return index >= 0 ? chain[index] : undefined
|
|
60
|
+
}
|
package/src/server/context.ts
CHANGED
|
@@ -78,6 +78,34 @@ export interface RouteSchema {
|
|
|
78
78
|
* and read by downstream policy (e.g. a partner-API surface refusing to expose `pii`/`secret`).
|
|
79
79
|
*/
|
|
80
80
|
readonly classification?: DataClassification
|
|
81
|
+
/**
|
|
82
|
+
* Inline **route-assurance evidence**: the enforcement posture this route carries, declared adjacent to
|
|
83
|
+
* the handler (e.g. `[NIFRA_ASSURANCE.AUTHENTICATED]`). Each id becomes route-scoped `declared` evidence
|
|
84
|
+
* in reflection, so a `nifra.assurance.ts` policy `require:` clause is satisfiable WITHOUT rewriting an
|
|
85
|
+
* in-handler guard into a `withRouteAssurance`-marked middleware. Ids are lowercase dot/dash segments
|
|
86
|
+
* (invalid ids throw at registration). Reflected for the assurance gate; never read on the hot path.
|
|
87
|
+
*
|
|
88
|
+
* This is an author ASSERTION (recorded as `source: "declared"`), not runtime proof - like every
|
|
89
|
+
* assurance marker it is trusted by the static gate, which matches on the evidence id, not its source.
|
|
90
|
+
* Declare an id only on a route whose guard actually enforces it; a false declaration silently satisfies
|
|
91
|
+
* the policy. (`source` is retained in reflection, so a stricter policy or reviewer can still tell
|
|
92
|
+
* declared evidence from enforcement-module evidence.)
|
|
93
|
+
*/
|
|
94
|
+
readonly assurance?: readonly string[]
|
|
95
|
+
/**
|
|
96
|
+
* Mark this route a **dynamic route family**: a template (`/api/:slug/:resource`) whose concrete
|
|
97
|
+
* resources are resolved at runtime (auto-CRUD over tenant-defined tables, a catch-all dispatcher).
|
|
98
|
+
* Purely declarative - it does not change dispatch. It surfaces in reflection so the assurance gate and
|
|
99
|
+
* tooling treat the one templated route as a deliberate family whose evidence covers every runtime-
|
|
100
|
+
* resolved resource, instead of reading it as a single forgotten route.
|
|
101
|
+
*/
|
|
102
|
+
readonly family?: boolean
|
|
103
|
+
/** Optional **path-params schema**. Path params arrive as strings (`/users/:id` -> `c.params.id`);
|
|
104
|
+
* declaring a schema validates them at the boundary (a malformed `:id` is a `422` before the handler,
|
|
105
|
+
* like `body`/`query`) and can coerce them (use `t.query({ id: t.integer() })` for a numeric param).
|
|
106
|
+
* The schema must cover every path param; with the strict default an undeclared path param is rejected.
|
|
107
|
+
* When omitted, `c.params` stays the path-inferred `Record<name, string>`. */
|
|
108
|
+
readonly params?: StandardSchemaV1
|
|
81
109
|
readonly body?: StandardSchemaV1
|
|
82
110
|
readonly query?: StandardSchemaV1
|
|
83
111
|
/** Optional **response contract**. When declared: the handler's return is type-checked against it
|
|
@@ -117,7 +145,7 @@ export interface RouteSchema {
|
|
|
117
145
|
readonly onValidationError?: (
|
|
118
146
|
issues: ReadonlyArray<StandardIssue>,
|
|
119
147
|
ctx: Context,
|
|
120
|
-
kind: "body" | "query",
|
|
148
|
+
kind: "body" | "query" | "params",
|
|
121
149
|
) => Response | unknown | Promise<Response | unknown>
|
|
122
150
|
}
|
|
123
151
|
|
|
@@ -131,6 +159,13 @@ type QueryOf<S extends RouteSchema> = S extends { query: infer Q extends Standar
|
|
|
131
159
|
? InferOutput<Q>
|
|
132
160
|
: URLSearchParams
|
|
133
161
|
|
|
162
|
+
/** The validated params type when a params schema is declared, else the path-inferred `Params<Path>`. */
|
|
163
|
+
type ParamsOf<S extends RouteSchema, Path extends string> = S extends {
|
|
164
|
+
params: infer P extends StandardSchemaV1
|
|
165
|
+
}
|
|
166
|
+
? InferOutput<P>
|
|
167
|
+
: Params<Path>
|
|
168
|
+
|
|
134
169
|
/** Mutable response controls a handler may write to before returning. */
|
|
135
170
|
export interface ResponseControls {
|
|
136
171
|
status?: number
|
|
@@ -159,6 +194,10 @@ export interface Platform<Env = unknown> {
|
|
|
159
194
|
readonly env?: Env
|
|
160
195
|
/** Extend the response's lifetime for background work (Workers `ctx.waitUntil`). */
|
|
161
196
|
readonly waitUntil?: (promise: Promise<unknown>) => void
|
|
197
|
+
/** The raw socket peer address the serving adapter observed, if any (`listen()`, `@nifrajs/node`,
|
|
198
|
+
* `@nifrajs/deno` set it). The server applies the app's `clientIp` trust declaration to this before
|
|
199
|
+
* exposing {@link Context.clientIp}; read `c.clientIp`, not this. */
|
|
200
|
+
readonly clientIp?: string | undefined
|
|
162
201
|
}
|
|
163
202
|
|
|
164
203
|
/**
|
|
@@ -174,7 +213,7 @@ export interface Context<Path extends string = string, S extends RouteSchema = R
|
|
|
174
213
|
* everywhere (no more `c.req` vs `ctx.request` mismatch).
|
|
175
214
|
*/
|
|
176
215
|
readonly request: Request
|
|
177
|
-
readonly params:
|
|
216
|
+
readonly params: ParamsOf<S, Path>
|
|
178
217
|
readonly query: QueryOf<S>
|
|
179
218
|
readonly body: BodyOf<S>
|
|
180
219
|
/** The request's cookies, parsed from the `Cookie` header (values URL-decoded). Parsed lazily on
|
|
@@ -198,6 +237,15 @@ export interface Context<Path extends string = string, S extends RouteSchema = R
|
|
|
198
237
|
* (`c.env: Env`); otherwise `unknown`. Validate at the trust boundary before use either way.
|
|
199
238
|
*/
|
|
200
239
|
readonly env: unknown
|
|
240
|
+
/**
|
|
241
|
+
* The caller's IP. By default the raw socket peer the serving adapter observed (`listen()`,
|
|
242
|
+
* `@nifrajs/node`, `@nifrajs/deno`) — the one address a client cannot forge — or `undefined` when the
|
|
243
|
+
* runtime exposes no socket (Workers) and no trust is declared. Behind a proxy/CDN, set the
|
|
244
|
+
* `clientIp` server option (`{ trustedHops }` or `{ header }`) to derive the real caller from the
|
|
245
|
+
* forwarding chain as far as you trust it; unset, no forwarded header is ever believed. Safe to key
|
|
246
|
+
* rate limits and audit logs on.
|
|
247
|
+
*/
|
|
248
|
+
readonly clientIp: string | undefined
|
|
201
249
|
/**
|
|
202
250
|
* Schedule background work. On edge runtimes it extends the response lifetime (Workers
|
|
203
251
|
* `ctx.waitUntil`); off-edge it runs the promise fire-and-forget (a rejection is swallowed,
|
package/src/server/contract.ts
CHANGED
|
@@ -26,6 +26,8 @@ export interface ResponseDef {
|
|
|
26
26
|
export interface OperationDef {
|
|
27
27
|
readonly method: Method
|
|
28
28
|
readonly path: string
|
|
29
|
+
/** Optional path-params schema (validated + coercible at the boundary; see {@link RouteSchema.params}). */
|
|
30
|
+
readonly params?: StandardSchemaV1
|
|
29
31
|
readonly body?: StandardSchemaV1
|
|
30
32
|
readonly query?: StandardSchemaV1
|
|
31
33
|
readonly response?: StandardSchemaV1
|
|
@@ -35,6 +37,10 @@ export interface OperationDef {
|
|
|
35
37
|
readonly idempotency?: IdempotencyConfig
|
|
36
38
|
/** Highest data-sensitivity the response carries (see {@link RouteSchema.classification}). */
|
|
37
39
|
readonly classification?: DataClassification
|
|
40
|
+
/** Inline route-assurance evidence this operation carries (see {@link RouteSchema.assurance}). */
|
|
41
|
+
readonly assurance?: readonly string[]
|
|
42
|
+
/** Mark this operation a dynamic route family (see {@link RouteSchema.family}). */
|
|
43
|
+
readonly family?: boolean
|
|
38
44
|
/** Short summary (OpenAPI `summary`). */
|
|
39
45
|
readonly summary?: string
|
|
40
46
|
/** Longer description (OpenAPI `description`, CommonMark). */
|
|
@@ -68,21 +74,27 @@ type OpResponse<O extends OperationDef> = O extends { response: infer R extends
|
|
|
68
74
|
|
|
69
75
|
/** The body types of a contract op's declared **non-2xx** `responses` (schema-bearing ones), keyed by status. */
|
|
70
76
|
type OpErrorBodies<Rs extends Record<string, ResponseDef>> = {
|
|
71
|
-
|
|
77
|
+
// Non-2xx statuses, re-keyed from the contract's string keys ("404") to number literals (404) so
|
|
78
|
+
// the client's failure union discriminates on the numeric `status` it actually receives.
|
|
79
|
+
[K in keyof Rs as K extends `2${string}`
|
|
80
|
+
? never
|
|
81
|
+
: K extends `${infer N extends number}`
|
|
82
|
+
? N
|
|
83
|
+
: never]: Rs[K] extends {
|
|
72
84
|
schema: infer S extends StandardSchemaV1
|
|
73
85
|
}
|
|
74
86
|
? InferOutput<S>
|
|
75
87
|
: never
|
|
76
88
|
}
|
|
77
89
|
|
|
78
|
-
/** The client-visible error
|
|
79
|
-
*
|
|
90
|
+
/** The client-visible error bodies for a contract op as a status-keyed record (`{ 404: Body }`), or
|
|
91
|
+
* `unknown` when it declares none (mirrors an inline route with no `errors`). */
|
|
80
92
|
type OpErrors<O extends OperationDef> = O extends {
|
|
81
93
|
responses: infer Rs extends Record<string, ResponseDef>
|
|
82
94
|
}
|
|
83
|
-
? [
|
|
95
|
+
? [keyof OpErrorBodies<Rs>] extends [never]
|
|
84
96
|
? unknown
|
|
85
|
-
: OpErrorBodies<Rs>
|
|
97
|
+
: OpErrorBodies<Rs>
|
|
86
98
|
: unknown
|
|
87
99
|
|
|
88
100
|
/**
|
|
@@ -116,7 +128,8 @@ export type RegistryFor<C extends ContractShape> = {
|
|
|
116
128
|
type SchemaForOp<O extends OperationDef> = (O extends { body: infer B extends StandardSchemaV1 }
|
|
117
129
|
? { body: B }
|
|
118
130
|
: Record<never, never>) &
|
|
119
|
-
(O extends { query: infer Q extends StandardSchemaV1 } ? { query: Q } : Record<never, never>)
|
|
131
|
+
(O extends { query: infer Q extends StandardSchemaV1 } ? { query: Q } : Record<never, never>) &
|
|
132
|
+
(O extends { params: infer P extends StandardSchemaV1 } ? { params: P } : Record<never, never>)
|
|
120
133
|
|
|
121
134
|
/**
|
|
122
135
|
* The handler context for an op — identical to the inline `Context<Path, S>`, so a
|
|
@@ -251,19 +264,25 @@ export function implement<
|
|
|
251
264
|
// once here at bind time, not per request — it just carries a reference to the op's existing schema.
|
|
252
265
|
// A response-less op still yields `undefined`, byte-identical to before.
|
|
253
266
|
const schema: RouteSchema | undefined =
|
|
267
|
+
op.params !== undefined ||
|
|
254
268
|
op.body !== undefined ||
|
|
255
269
|
op.query !== undefined ||
|
|
256
270
|
op.response !== undefined ||
|
|
257
271
|
op.capabilities !== undefined ||
|
|
258
272
|
op.idempotency !== undefined ||
|
|
259
|
-
op.classification !== undefined
|
|
273
|
+
op.classification !== undefined ||
|
|
274
|
+
op.assurance !== undefined ||
|
|
275
|
+
op.family !== undefined
|
|
260
276
|
? {
|
|
277
|
+
...(op.params !== undefined ? { params: op.params } : {}),
|
|
261
278
|
...(op.body !== undefined ? { body: op.body } : {}),
|
|
262
279
|
...(op.query !== undefined ? { query: op.query } : {}),
|
|
263
280
|
...(op.response !== undefined ? { response: op.response } : {}),
|
|
264
281
|
...(op.capabilities !== undefined ? { capabilities: op.capabilities } : {}),
|
|
265
282
|
...(op.idempotency !== undefined ? { idempotency: op.idempotency } : {}),
|
|
266
283
|
...(op.classification !== undefined ? { classification: op.classification } : {}),
|
|
284
|
+
...(op.assurance !== undefined ? { assurance: op.assurance } : {}),
|
|
285
|
+
...(op.family !== undefined ? { family: op.family } : {}),
|
|
267
286
|
}
|
|
268
287
|
: undefined
|
|
269
288
|
return {
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dependency-free HTTP helpers shared by the kernel and the opt-in request lanes (idempotency,
|
|
3
|
+
* effect-ledger). Kept in a leaf module so a lane can reuse them without importing the server, and so
|
|
4
|
+
* the server can reuse them without pulling a lane's feature code into the base bundle.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** A uniform JSON error envelope: `{ ok: false, error }` at the given status. */
|
|
8
|
+
export function jsonError(
|
|
9
|
+
status: number,
|
|
10
|
+
error: string,
|
|
11
|
+
headers?: Record<string, string>,
|
|
12
|
+
): Response {
|
|
13
|
+
return Response.json(
|
|
14
|
+
{ ok: false, error },
|
|
15
|
+
headers !== undefined ? { status, headers } : { status },
|
|
16
|
+
)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface UrlParts {
|
|
20
|
+
readonly pathname: string
|
|
21
|
+
readonly search: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Extract pathname + query WITHOUT a full WHATWG `new URL(req.url)` parse.
|
|
25
|
+
// `req.url` from every supported runtime is an absolute, already-normalized URL, so the pathname is
|
|
26
|
+
// the substring after `scheme://host[:port]` up to `?`/`#`. Query-schema routes also need the search
|
|
27
|
+
// string; parsing both in one scanner avoids the old `pathnameOf()` + `searchOf()` double scan.
|
|
28
|
+
export function urlPartsOf(url: string): UrlParts {
|
|
29
|
+
const schemeEnd = url.indexOf("://")
|
|
30
|
+
const start = schemeEnd === -1 ? url.indexOf("/") : url.indexOf("/", schemeEnd + 3)
|
|
31
|
+
if (start === -1) return { pathname: "/", search: "" }
|
|
32
|
+
|
|
33
|
+
let pathEnd = url.length
|
|
34
|
+
let searchStart = -1
|
|
35
|
+
let searchEnd = url.length
|
|
36
|
+
for (let i = start; i < url.length; i++) {
|
|
37
|
+
const c = url.charCodeAt(i)
|
|
38
|
+
if (c === 63 /* ? */ && searchStart === -1) {
|
|
39
|
+
pathEnd = i
|
|
40
|
+
searchStart = i
|
|
41
|
+
} else if (c === 35 /* # */) {
|
|
42
|
+
if (searchStart === -1) pathEnd = i
|
|
43
|
+
searchEnd = i
|
|
44
|
+
break
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
pathname: url.slice(start, pathEnd),
|
|
50
|
+
search: searchStart === -1 ? "" : url.slice(searchStart, searchEnd),
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Extract the pathname WITHOUT a full WHATWG `new URL(req.url)` parse. Kept as a public-ish helper
|
|
55
|
+
// for tests and callers that only need the path; the request hot path uses `urlPartsOf()` once.
|
|
56
|
+
export function pathnameOf(url: string): string {
|
|
57
|
+
return urlPartsOf(url).pathname
|
|
58
|
+
}
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The opt-in idempotency request lane. Installed by `.use(idempotency())` and reached only by routes
|
|
3
|
+
* that declare `schema.idempotency`, so the kernel never statically imports this code: a bare
|
|
4
|
+
* `server()` tree-shakes the whole dedupe machinery out.
|
|
5
|
+
*
|
|
6
|
+
* The kernel resolves a route's declared idempotency at registration (via {@link IdempotencyRuntime.resolve})
|
|
7
|
+
* and, on the request path, hands a matched idempotent route to {@link IdempotencyRuntime.run}. Everything
|
|
8
|
+
* server-specific is supplied through {@link IdempotencyHost} so this module depends on no server internals.
|
|
9
|
+
*/
|
|
10
|
+
import { RouteConfigError } from "../errors.ts"
|
|
11
|
+
import {
|
|
12
|
+
canonicalizeIdempotencyBody,
|
|
13
|
+
computeIdempotencyFingerprint,
|
|
14
|
+
DEFAULT_IDEMPOTENCY_HEADER,
|
|
15
|
+
DEFAULT_IDEMPOTENCY_TTL_MS,
|
|
16
|
+
IdempotencyResponseTooLargeError,
|
|
17
|
+
type IdempotencyStore,
|
|
18
|
+
MemoryIdempotencyStore,
|
|
19
|
+
responseFromStored,
|
|
20
|
+
serializeResponse,
|
|
21
|
+
validIdempotencyKey,
|
|
22
|
+
validIdempotencyNamespace,
|
|
23
|
+
} from "../idempotency.ts"
|
|
24
|
+
import {
|
|
25
|
+
beginRequestEffectTracking,
|
|
26
|
+
markEffect,
|
|
27
|
+
requestEffectEvidence,
|
|
28
|
+
requestIsSafeToRetry,
|
|
29
|
+
} from "../internal/effect-execution.ts"
|
|
30
|
+
import { readBoundedBytes } from "./body.ts"
|
|
31
|
+
import type { Platform, RouteSchema } from "./context.ts"
|
|
32
|
+
import { jsonError, urlPartsOf } from "./http.ts"
|
|
33
|
+
import { INSTALL_IDEMPOTENCY } from "./install.ts"
|
|
34
|
+
import type { AnyServer, IdentityPlugin } from "./server.ts"
|
|
35
|
+
|
|
36
|
+
/** Registration-resolved idempotency for a route: the config with its store + defaults pinned. */
|
|
37
|
+
export interface ResolvedIdempotency {
|
|
38
|
+
readonly store: IdempotencyStore
|
|
39
|
+
readonly ttlMs: number
|
|
40
|
+
readonly headerName: string
|
|
41
|
+
readonly namespace: NonNullable<RouteSchema["idempotency"]>["namespace"]
|
|
42
|
+
readonly maxResponseBytes: number
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* What the dedupe lane needs from the server to run a fresh key: the app-wide body cap and a way to
|
|
47
|
+
* run the buffered request through the route's normal lanes to a concrete `Response`.
|
|
48
|
+
*/
|
|
49
|
+
export interface IdempotencyHost {
|
|
50
|
+
readonly maxBodyBytes: number
|
|
51
|
+
runLanes(
|
|
52
|
+
buffered: Request,
|
|
53
|
+
platform: Platform | undefined,
|
|
54
|
+
entry: unknown,
|
|
55
|
+
params: Record<string, string>,
|
|
56
|
+
search: string | undefined,
|
|
57
|
+
): Promise<Response>
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The injected idempotency implementation the kernel calls through when the plugin is installed. */
|
|
61
|
+
export interface IdempotencyRuntime {
|
|
62
|
+
readonly trackEffect: (context: object, committed: boolean) => void
|
|
63
|
+
/** Resolve a route's declared idempotency into a pinned store + defaults, or `undefined` when off. */
|
|
64
|
+
resolve(
|
|
65
|
+
schema: RouteSchema | undefined,
|
|
66
|
+
authenticated: boolean,
|
|
67
|
+
maxBodyBytes: number,
|
|
68
|
+
): ResolvedIdempotency | undefined
|
|
69
|
+
/** The dedupe lane: read the key, fingerprint the request, consult the store, run/replay/reject. */
|
|
70
|
+
run<T>(
|
|
71
|
+
config: ResolvedIdempotency,
|
|
72
|
+
req: Request,
|
|
73
|
+
platform: Platform | undefined,
|
|
74
|
+
entry: unknown,
|
|
75
|
+
params: Record<string, string>,
|
|
76
|
+
search: string | undefined,
|
|
77
|
+
wrapResponse: (response: Response) => T,
|
|
78
|
+
host: IdempotencyHost,
|
|
79
|
+
): Promise<T>
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The install seam a server exposes so the `idempotency()` plugin can hand it a runtime. */
|
|
83
|
+
interface IdempotencyInstallable {
|
|
84
|
+
[INSTALL_IDEMPOTENCY](runtime: IdempotencyRuntime): void
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Enable request idempotency. Routes that declare `schema.idempotency` get the dedupe lane: a repeat
|
|
89
|
+
* `Idempotency-Key` replays the stored response instead of re-running the handler. Without this plugin,
|
|
90
|
+
* declaring `schema.idempotency` is a registration error (the safety gate can never be silently
|
|
91
|
+
* dropped). `store` is the app-wide default backing routes that do not pin their own; omit it for a
|
|
92
|
+
* shared in-process store (development/tests only - inject a durable store in production).
|
|
93
|
+
*/
|
|
94
|
+
export interface IdempotencyPluginOptions {
|
|
95
|
+
readonly store?: IdempotencyStore | undefined
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function idempotency(options?: IdempotencyPluginOptions): IdentityPlugin {
|
|
99
|
+
const runtime = createIdempotencyRuntime(options)
|
|
100
|
+
const apply = <S extends AnyServer>(app: S): S => {
|
|
101
|
+
;(app as unknown as IdempotencyInstallable)[INSTALL_IDEMPOTENCY](runtime)
|
|
102
|
+
return app
|
|
103
|
+
}
|
|
104
|
+
return Object.assign(apply, { pluginName: "nifra:idempotency" }) as IdentityPlugin
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Build an idempotency runtime. `store` is the app-wide default backing routes that declare
|
|
109
|
+
* `schema.idempotency` without their own `store`; when omitted, a shared in-process
|
|
110
|
+
* {@link MemoryIdempotencyStore} is created lazily on first idempotent route.
|
|
111
|
+
*/
|
|
112
|
+
export function createIdempotencyRuntime(options?: IdempotencyPluginOptions): IdempotencyRuntime {
|
|
113
|
+
const configuredStore = options?.store
|
|
114
|
+
let lazyStore: IdempotencyStore | undefined
|
|
115
|
+
const defaultStore = (): IdempotencyStore => {
|
|
116
|
+
if (configuredStore !== undefined) return configuredStore
|
|
117
|
+
lazyStore ??= new MemoryIdempotencyStore()
|
|
118
|
+
return lazyStore
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
trackEffect(context, committed) {
|
|
123
|
+
markEffect(context, committed)
|
|
124
|
+
},
|
|
125
|
+
resolve(schema, authenticated, maxBodyBytes) {
|
|
126
|
+
const config = schema?.idempotency
|
|
127
|
+
if (config === undefined) return undefined
|
|
128
|
+
if (schema?.sse !== undefined) {
|
|
129
|
+
throw new RouteConfigError(
|
|
130
|
+
"INVALID_IDEMPOTENCY",
|
|
131
|
+
"streaming/SSE responses cannot be used on an idempotency route",
|
|
132
|
+
)
|
|
133
|
+
}
|
|
134
|
+
const store = config.store ?? defaultStore()
|
|
135
|
+
if (config.scope === "durable" && store.durability !== "durable") {
|
|
136
|
+
throw new RouteConfigError(
|
|
137
|
+
"INVALID_IDEMPOTENCY",
|
|
138
|
+
'durable idempotency requires a durable store (store.durability must be "durable")',
|
|
139
|
+
)
|
|
140
|
+
}
|
|
141
|
+
const ttlMs = config.ttlMs ?? DEFAULT_IDEMPOTENCY_TTL_MS
|
|
142
|
+
if (!Number.isSafeInteger(ttlMs) || ttlMs <= 0) {
|
|
143
|
+
throw new RouteConfigError(
|
|
144
|
+
"INVALID_IDEMPOTENCY",
|
|
145
|
+
"idempotency ttlMs must be a finite positive number",
|
|
146
|
+
)
|
|
147
|
+
}
|
|
148
|
+
const headerName = (config.headerName ?? DEFAULT_IDEMPOTENCY_HEADER).toLowerCase()
|
|
149
|
+
try {
|
|
150
|
+
new Headers().set(headerName, "probe")
|
|
151
|
+
} catch {
|
|
152
|
+
throw new RouteConfigError(
|
|
153
|
+
"INVALID_IDEMPOTENCY",
|
|
154
|
+
`invalid idempotency header name ${JSON.stringify(headerName)}`,
|
|
155
|
+
)
|
|
156
|
+
}
|
|
157
|
+
const namespace = config.namespace
|
|
158
|
+
if (namespace === undefined) {
|
|
159
|
+
throw new RouteConfigError(
|
|
160
|
+
"INVALID_IDEMPOTENCY",
|
|
161
|
+
"idempotency namespace is required; use a principal resolver or an explicit shared scope",
|
|
162
|
+
)
|
|
163
|
+
}
|
|
164
|
+
if (typeof namespace === "string" && !validIdempotencyNamespace(namespace)) {
|
|
165
|
+
throw new RouteConfigError(
|
|
166
|
+
"INVALID_IDEMPOTENCY",
|
|
167
|
+
`invalid idempotency namespace ${JSON.stringify(namespace)}`,
|
|
168
|
+
)
|
|
169
|
+
}
|
|
170
|
+
if (authenticated && typeof namespace !== "function") {
|
|
171
|
+
throw new RouteConfigError(
|
|
172
|
+
"INVALID_IDEMPOTENCY",
|
|
173
|
+
"authenticated idempotency routes require a principal namespace resolver",
|
|
174
|
+
)
|
|
175
|
+
}
|
|
176
|
+
const maxResponseBytes = config.maxResponseBytes ?? maxBodyBytes
|
|
177
|
+
if (!Number.isSafeInteger(maxResponseBytes) || maxResponseBytes < 1) {
|
|
178
|
+
throw new RouteConfigError(
|
|
179
|
+
"INVALID_IDEMPOTENCY",
|
|
180
|
+
"idempotency maxResponseBytes must be a positive integer",
|
|
181
|
+
)
|
|
182
|
+
}
|
|
183
|
+
return Object.freeze({
|
|
184
|
+
store,
|
|
185
|
+
ttlMs,
|
|
186
|
+
headerName,
|
|
187
|
+
namespace,
|
|
188
|
+
maxResponseBytes,
|
|
189
|
+
})
|
|
190
|
+
},
|
|
191
|
+
|
|
192
|
+
async run(config, req, platform, entry, params, search, wrapResponse, host) {
|
|
193
|
+
const key = req.headers.get(config.headerName)
|
|
194
|
+
if (key === null || !validIdempotencyKey(key)) {
|
|
195
|
+
return wrapResponse(jsonError(400, "idempotency_key_required"))
|
|
196
|
+
}
|
|
197
|
+
const namespace =
|
|
198
|
+
typeof config.namespace === "function"
|
|
199
|
+
? await config.namespace(req.clone(), platform)
|
|
200
|
+
: config.namespace
|
|
201
|
+
if (!validIdempotencyNamespace(namespace)) {
|
|
202
|
+
return wrapResponse(jsonError(500, "idempotency_namespace_invalid"))
|
|
203
|
+
}
|
|
204
|
+
const read = await readBoundedBytes(req, host.maxBodyBytes)
|
|
205
|
+
if (!read.ok) {
|
|
206
|
+
return wrapResponse(
|
|
207
|
+
jsonError(read.status, read.status === 413 ? "payload_too_large" : "bad_request"),
|
|
208
|
+
)
|
|
209
|
+
}
|
|
210
|
+
const url = urlPartsOf(req.url)
|
|
211
|
+
const canonicalBody = canonicalizeIdempotencyBody(read.bytes, req.headers.get("content-type"))
|
|
212
|
+
const fingerprint = await computeIdempotencyFingerprint(
|
|
213
|
+
req.method,
|
|
214
|
+
`${url.pathname}${url.search}`,
|
|
215
|
+
canonicalBody,
|
|
216
|
+
req.headers.get("content-type") ?? "",
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
const begin = await config.store.begin({ namespace, key, fingerprint, ttlMs: config.ttlMs })
|
|
220
|
+
if (begin.state === "replay") {
|
|
221
|
+
return wrapResponse(
|
|
222
|
+
responseFromStored(begin.response, { maxBytes: config.maxResponseBytes }),
|
|
223
|
+
)
|
|
224
|
+
}
|
|
225
|
+
if (begin.state === "mismatch") return wrapResponse(jsonError(409, "idempotency_key_reused"))
|
|
226
|
+
if (begin.state === "in-flight") {
|
|
227
|
+
return wrapResponse(jsonError(409, "idempotency_in_progress", { "Retry-After": "1" }))
|
|
228
|
+
}
|
|
229
|
+
if (begin.state === "capacity") {
|
|
230
|
+
return wrapResponse(jsonError(503, "idempotency_store_capacity", { "Retry-After": "1" }))
|
|
231
|
+
}
|
|
232
|
+
const reservation = begin.reservation
|
|
233
|
+
|
|
234
|
+
// Fresh key: re-expose the buffered body as a Request so the normal lanes can read + validate it,
|
|
235
|
+
// then run those lanes to a concrete Response.
|
|
236
|
+
const bufferedInit: RequestInit = {
|
|
237
|
+
method: req.method,
|
|
238
|
+
headers: req.headers,
|
|
239
|
+
signal: req.signal,
|
|
240
|
+
}
|
|
241
|
+
if (read.bytes.byteLength > 0)
|
|
242
|
+
bufferedInit.body = read.bytes as NonNullable<RequestInit["body"]>
|
|
243
|
+
const buffered = new Request(req.url, bufferedInit)
|
|
244
|
+
beginRequestEffectTracking(buffered)
|
|
245
|
+
let response: Response
|
|
246
|
+
try {
|
|
247
|
+
response = await host.runLanes(buffered, platform, entry, params, search)
|
|
248
|
+
} catch (err) {
|
|
249
|
+
const evidence = requestEffectEvidence(buffered)
|
|
250
|
+
if (!evidence.began) {
|
|
251
|
+
// The request-local boundary proves no owned effect started, so this is the one safe case
|
|
252
|
+
// where releasing the key cannot duplicate an effect.
|
|
253
|
+
await config.store.abandon({ namespace, key, reservation })
|
|
254
|
+
throw err
|
|
255
|
+
}
|
|
256
|
+
// A committed or ambiguous effect must never be repeated. Persist a payload-free terminal
|
|
257
|
+
// response even though the normal handler lifecycle escaped without producing one.
|
|
258
|
+
response = new Response(null, { status: 500 })
|
|
259
|
+
const completed = await config.store.complete({
|
|
260
|
+
namespace,
|
|
261
|
+
key,
|
|
262
|
+
reservation,
|
|
263
|
+
response: await serializeResponse(response, { maxBytes: config.maxResponseBytes }),
|
|
264
|
+
})
|
|
265
|
+
if (!completed) {
|
|
266
|
+
return wrapResponse(
|
|
267
|
+
jsonError(503, "idempotency_reservation_lost", { "Retry-After": "1" }),
|
|
268
|
+
)
|
|
269
|
+
}
|
|
270
|
+
return wrapResponse(response)
|
|
271
|
+
}
|
|
272
|
+
if (
|
|
273
|
+
response.status >= 500 &&
|
|
274
|
+
requestIsSafeToRetry(buffered) &&
|
|
275
|
+
!requestEffectEvidence(buffered).began
|
|
276
|
+
) {
|
|
277
|
+
const abandoned = await config.store.abandon({ namespace, key, reservation })
|
|
278
|
+
if (!abandoned) {
|
|
279
|
+
return wrapResponse(
|
|
280
|
+
jsonError(503, "idempotency_reservation_lost", { "Retry-After": "1" }),
|
|
281
|
+
)
|
|
282
|
+
}
|
|
283
|
+
return wrapResponse(response)
|
|
284
|
+
}
|
|
285
|
+
// Once the handler ran, every concrete response is terminal for this key. A non-2xx may follow
|
|
286
|
+
// an already-committed external effect; abandoning it would let a retry duplicate that effect.
|
|
287
|
+
let storedResponse: Awaited<ReturnType<typeof serializeResponse>>
|
|
288
|
+
try {
|
|
289
|
+
storedResponse = await serializeResponse(response, { maxBytes: config.maxResponseBytes })
|
|
290
|
+
} catch (error) {
|
|
291
|
+
if (!(error instanceof IdempotencyResponseTooLargeError)) throw error
|
|
292
|
+
// The effect may already have happened, so never abandon and permit a duplicate execution.
|
|
293
|
+
// Commit a small terminal response under the winning key and return that same response now.
|
|
294
|
+
response = jsonError(507, "idempotency_response_too_large")
|
|
295
|
+
try {
|
|
296
|
+
storedResponse = await serializeResponse(response, {
|
|
297
|
+
maxBytes: config.maxResponseBytes,
|
|
298
|
+
})
|
|
299
|
+
} catch (terminalError) {
|
|
300
|
+
if (!(terminalError instanceof IdempotencyResponseTooLargeError)) throw terminalError
|
|
301
|
+
// Even an intentionally tiny bound must remain truthful. An empty 507 preserves terminal
|
|
302
|
+
// status + replay safety without silently storing more bytes than the route permits.
|
|
303
|
+
response = new Response(null, { status: 507 })
|
|
304
|
+
storedResponse = await serializeResponse(response, {
|
|
305
|
+
maxBytes: config.maxResponseBytes,
|
|
306
|
+
})
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
const completed = await config.store.complete({
|
|
310
|
+
namespace,
|
|
311
|
+
key,
|
|
312
|
+
reservation,
|
|
313
|
+
response: storedResponse,
|
|
314
|
+
})
|
|
315
|
+
if (!completed) {
|
|
316
|
+
return wrapResponse(jsonError(503, "idempotency_reservation_lost", { "Retry-After": "1" }))
|
|
317
|
+
}
|
|
318
|
+
return wrapResponse(response)
|
|
319
|
+
},
|
|
320
|
+
}
|
|
321
|
+
}
|