@nifrajs/core 2.0.0 → 2.2.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 +27 -0
- 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-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 +5 -0
- package/dist/idempotency-plugin.d.ts.map +1 -1
- package/dist/idempotency-plugin.js +8 -0
- package/dist/idempotency-plugin.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +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/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/router/pattern.d.ts +26 -1
- package/dist/router/pattern.d.ts.map +1 -1
- package/dist/router/pattern.js +164 -8
- package/dist/router/pattern.js.map +1 -1
- package/dist/router/router.d.ts.map +1 -1
- package/dist/router/router.js +49 -1
- package/dist/router/router.js.map +1 -1
- package/dist/server/idempotency-lane.d.ts +1 -0
- package/dist/server/idempotency-lane.d.ts.map +1 -1
- package/dist/server/idempotency-lane.js +61 -37
- package/dist/server/idempotency-lane.js.map +1 -1
- package/dist/server/respond.d.ts.map +1 -1
- package/dist/server/respond.js.map +1 -1
- package/dist/server/server.d.ts +12 -0
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +33 -2
- package/dist/server/server.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 +6 -0
- 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/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 +214 -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/package.json +46 -1
- package/src/capabilities.ts +306 -5
- package/src/durable-adapters.ts +1039 -0
- package/src/durable-execution.ts +1676 -0
- package/src/effect-lifecycle.ts +120 -0
- package/src/effect-scope.ts +267 -0
- package/src/idempotency-plugin.ts +10 -0
- package/src/index.ts +1 -1
- 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/reconciliation-worker.ts +372 -0
- package/src/router/pattern.ts +179 -8
- package/src/router/router.ts +75 -1
- package/src/server/idempotency-lane.ts +68 -34
- package/src/server/respond.ts +1 -0
- package/src/server/server.ts +46 -0
- package/src/server/transport-lane.ts +141 -0
- package/src/server/websocket.ts +15 -1
- package/src/transport-codec-rich.ts +20 -0
- package/src/transport-codec.ts +284 -0
- package/src/transport-plugin.ts +2 -0
- package/src/wire.ts +474 -0
package/src/router/pattern.ts
CHANGED
|
@@ -8,6 +8,13 @@ export type RoutePatternSegment =
|
|
|
8
8
|
| { readonly kind: "static"; readonly value: string }
|
|
9
9
|
| { readonly kind: "param"; readonly name: string }
|
|
10
10
|
| { readonly kind: "wildcard"; readonly name: string }
|
|
11
|
+
/** A segment that is part literal, part parameter: `:key.txt`, `feed.:format`, `v:major.:minor`. */
|
|
12
|
+
| { readonly kind: "mixed"; readonly parts: readonly MixedPart[] }
|
|
13
|
+
|
|
14
|
+
/** One piece of a {@link RoutePatternSegment} of kind `mixed`, in left-to-right order. */
|
|
15
|
+
export type MixedPart =
|
|
16
|
+
| { readonly t: "lit"; readonly v: string }
|
|
17
|
+
| { readonly t: "param"; readonly name: string }
|
|
11
18
|
|
|
12
19
|
/**
|
|
13
20
|
* Compiled route grammar shared by runtime routers, browser navigation, mocks, and adapters.
|
|
@@ -45,7 +52,9 @@ function regexOf(compiled: CompiledRoutePattern): RegExp {
|
|
|
45
52
|
? escapeRegex(segment.value)
|
|
46
53
|
: segment.kind === "param"
|
|
47
54
|
? "([^/]+)"
|
|
48
|
-
: "
|
|
55
|
+
: segment.kind === "mixed"
|
|
56
|
+
? mixedSegmentSource(segment.parts)
|
|
57
|
+
: "(.+)",
|
|
49
58
|
)
|
|
50
59
|
regex = new RegExp(parts.length === 0 ? "^/$" : `^/${parts.join("/")}$`)
|
|
51
60
|
REGEX_CACHE.set(compiled, regex)
|
|
@@ -57,10 +66,93 @@ function validParamName(name: string): boolean {
|
|
|
57
66
|
return PARAM_NAME.test(name) && !RESERVED_PARAM_NAMES.has(name)
|
|
58
67
|
}
|
|
59
68
|
|
|
69
|
+
/**
|
|
70
|
+
* Explain WHY a parameter name was rejected, in the terms the author was thinking in.
|
|
71
|
+
*
|
|
72
|
+
* The grammar is per-segment: a segment is wholly static or wholly a parameter, so everything after
|
|
73
|
+
* the colon is the name. `/v/:id.json` therefore asks for a parameter literally named `id.json`,
|
|
74
|
+
* which is not what anyone means by it — but the bare "invalid parameter" that produces reads as a
|
|
75
|
+
* typo rather than as a rule, leaving the author to guess whether the dot, the length, or the casing
|
|
76
|
+
* was the problem. Naming the actual limitation and showing the two ways out is the difference
|
|
77
|
+
* between a five-second fix and a trip to the source.
|
|
78
|
+
*/
|
|
79
|
+
function paramNameHint(name: string): string {
|
|
80
|
+
if (RESERVED_PARAM_NAMES.has(name)) return ` — "${name}" is reserved (prototype key)`
|
|
81
|
+
if (name.length === 0) return ` — ":" needs a name after it`
|
|
82
|
+
// No "a segment is wholly static or wholly a parameter" case here any more: mixed segments made
|
|
83
|
+
// that message obsolete. `:id.json` was the shape it explained, and `:id.json` now compiles.
|
|
84
|
+
return ` — a name must match ${PARAM_NAME.source}`
|
|
85
|
+
}
|
|
86
|
+
|
|
60
87
|
function escapeRegex(value: string): string {
|
|
61
88
|
return value.replace(/[.*+?^()|[\]\\{}$]/g, "\\$&")
|
|
62
89
|
}
|
|
63
90
|
|
|
91
|
+
/**
|
|
92
|
+
* Split a segment containing `:` into literal and parameter parts.
|
|
93
|
+
*
|
|
94
|
+
* The param name is the LONGEST run matching {@link PARAM_NAME}'s body after each `:`; everything
|
|
95
|
+
* else in the segment is literal. So `:key.txt` is `[param key][lit ".txt"]`. Choosing a greedy scan
|
|
96
|
+
* on the existing sigil rather than a new `{key}.txt` syntax is a safety argument, not a taste one:
|
|
97
|
+
* every segment this newly accepts currently THROWS `INVALID_PARAM_NAME`, so no pattern that compiles
|
|
98
|
+
* today can change meaning.
|
|
99
|
+
*
|
|
100
|
+
* Returns `undefined` when the segment holds no parameter at all, so a literal colon
|
|
101
|
+
* (`/v1/things:batchGet` - legal in a URL path) stays a plain static segment.
|
|
102
|
+
*/
|
|
103
|
+
function splitMixed(value: string): MixedPart[] | undefined {
|
|
104
|
+
const parts: MixedPart[] = []
|
|
105
|
+
let literal = ""
|
|
106
|
+
let sawParam = false
|
|
107
|
+
for (let i = 0; i < value.length; i++) {
|
|
108
|
+
if (value[i] !== ":") {
|
|
109
|
+
literal += value[i]
|
|
110
|
+
continue
|
|
111
|
+
}
|
|
112
|
+
const rest = value.slice(i + 1)
|
|
113
|
+
const name = /^[A-Za-z_][A-Za-z0-9_]*/.exec(rest)?.[0]
|
|
114
|
+
// A `:` not followed by a valid name start is literal text, not a malformed parameter.
|
|
115
|
+
if (name === undefined) {
|
|
116
|
+
literal += ":"
|
|
117
|
+
continue
|
|
118
|
+
}
|
|
119
|
+
const paramEnd = i + 1 + name.length
|
|
120
|
+
const previous = i > 0 ? value[i - 1] : undefined
|
|
121
|
+
const precededByBoundary = previous === undefined || !/[A-Za-z0-9_]/.test(previous)
|
|
122
|
+
// Preserve established RPC-style literals such as `things:batchGet`. A colon embedded after an
|
|
123
|
+
// identifier and running to the end is literal; mixed params remain unambiguous at segment start,
|
|
124
|
+
// after punctuation (`post-:id`), or when followed by a literal suffix (`v:major.json`).
|
|
125
|
+
if (!precededByBoundary && paramEnd === value.length) {
|
|
126
|
+
literal += `:${name}`
|
|
127
|
+
i += name.length
|
|
128
|
+
continue
|
|
129
|
+
}
|
|
130
|
+
if (literal !== "") {
|
|
131
|
+
parts.push({ t: "lit", v: literal })
|
|
132
|
+
literal = ""
|
|
133
|
+
}
|
|
134
|
+
parts.push({ t: "param", name })
|
|
135
|
+
sawParam = true
|
|
136
|
+
i += name.length
|
|
137
|
+
}
|
|
138
|
+
if (!sawParam) return undefined
|
|
139
|
+
if (literal !== "") parts.push({ t: "lit", v: literal })
|
|
140
|
+
return parts
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** The regex source matching one segment's worth of a mixed pattern, with a capture per parameter. */
|
|
144
|
+
export function mixedSegmentSource(parts: readonly MixedPart[]): string {
|
|
145
|
+
let source = ""
|
|
146
|
+
for (const part of parts) {
|
|
147
|
+
// LAZY (`+?`), not greedy. A greedy capture swallows the trailing literal, so `/:key.txt` against
|
|
148
|
+
// `/abc.txt` would capture `abc.txt` and then fail to match `\.txt`. With `^…$` anchoring, the
|
|
149
|
+
// lazy form still yields `abc.txt` for `/abc.txt.txt` - the anchor forces the LAST `.txt` to be
|
|
150
|
+
// the literal. `+?` and not `*?`: an empty capture must not match (see the empty-segment rule).
|
|
151
|
+
source += part.t === "lit" ? escapeRegex(part.v) : "([^/]+?)"
|
|
152
|
+
}
|
|
153
|
+
return source
|
|
154
|
+
}
|
|
155
|
+
|
|
64
156
|
/** Parse and validate Nifra's strict route grammar once. Trailing slashes remain significant. */
|
|
65
157
|
export function compileRoutePattern(pattern: string): CompiledRoutePattern {
|
|
66
158
|
if (pattern.length === 0 || pattern.charCodeAt(0) !== 47 /* / */) {
|
|
@@ -71,14 +163,20 @@ export function compileRoutePattern(pattern: string): CompiledRoutePattern {
|
|
|
71
163
|
const paramNames: string[] = []
|
|
72
164
|
for (let i = 0; i < raw.length; i++) {
|
|
73
165
|
const value = raw[i]!
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
166
|
+
// A leading `:` whose name run spans the WHOLE segment is a plain parameter - the untouched fast
|
|
167
|
+
// path. When it does not (`:key.txt`), the segment is mixed and is handled below; but a leading
|
|
168
|
+
// `:` that yields no usable name at all (`:9lives`, `:`) was clearly meant as a parameter, so it
|
|
169
|
+
// still fails here rather than being silently reinterpreted as literal text.
|
|
170
|
+
if (value.charCodeAt(0) === 58 /* : */ && !validParamName(value.slice(1))) {
|
|
171
|
+
if (splitMixed(value) === undefined) {
|
|
172
|
+
const name = value.slice(1)
|
|
77
173
|
throw new RouteConfigError(
|
|
78
174
|
"INVALID_PARAM_NAME",
|
|
79
|
-
`invalid parameter ":${name}" in "${pattern}"`,
|
|
175
|
+
`invalid parameter ":${name}" in "${pattern}"${paramNameHint(name)}`,
|
|
80
176
|
)
|
|
81
177
|
}
|
|
178
|
+
} else if (value.charCodeAt(0) === 58 /* : */) {
|
|
179
|
+
const name = value.slice(1)
|
|
82
180
|
if (paramNames.includes(name)) {
|
|
83
181
|
throw new RouteConfigError(
|
|
84
182
|
"DUPLICATE_PARAM",
|
|
@@ -113,6 +211,32 @@ export function compileRoutePattern(pattern: string): CompiledRoutePattern {
|
|
|
113
211
|
segments.push(Object.freeze({ kind: "wildcard", name }))
|
|
114
212
|
continue
|
|
115
213
|
}
|
|
214
|
+
// A segment holding a `:` anywhere other than the front is part literal, part parameter. Checked
|
|
215
|
+
// last so the wholly-static and wholly-param fast paths above are untouched.
|
|
216
|
+
const mixed = value.includes(":") ? splitMixed(value) : undefined
|
|
217
|
+
if (mixed !== undefined) {
|
|
218
|
+
for (const part of mixed) {
|
|
219
|
+
if (part.t !== "param") continue
|
|
220
|
+
if (!validParamName(part.name)) {
|
|
221
|
+
throw new RouteConfigError(
|
|
222
|
+
"INVALID_PARAM_NAME",
|
|
223
|
+
`invalid parameter ":${part.name}" in "${pattern}"${paramNameHint(part.name)}`,
|
|
224
|
+
)
|
|
225
|
+
}
|
|
226
|
+
// Duplicates must be rejected WITHIN a segment (`/:a.:a`) as well as across segments,
|
|
227
|
+
// or two captures would race for one params key.
|
|
228
|
+
if (paramNames.includes(part.name)) {
|
|
229
|
+
throw new RouteConfigError(
|
|
230
|
+
"DUPLICATE_PARAM",
|
|
231
|
+
`duplicate parameter ":${part.name}" in "${pattern}"`,
|
|
232
|
+
)
|
|
233
|
+
}
|
|
234
|
+
// Pushed left-to-right so `paramNames` stays aligned with regex capture order.
|
|
235
|
+
paramNames.push(part.name)
|
|
236
|
+
}
|
|
237
|
+
segments.push(Object.freeze({ kind: "mixed", parts: Object.freeze(mixed) }))
|
|
238
|
+
continue
|
|
239
|
+
}
|
|
116
240
|
segments.push(Object.freeze({ kind: "static", value }))
|
|
117
241
|
}
|
|
118
242
|
const compiled = Object.freeze({
|
|
@@ -124,7 +248,50 @@ export function compileRoutePattern(pattern: string): CompiledRoutePattern {
|
|
|
124
248
|
return compiled
|
|
125
249
|
}
|
|
126
250
|
|
|
127
|
-
/**
|
|
251
|
+
/** Weight for {@link compareRoutePatternSpecificity}. A mixed segment constrains more than a bare
|
|
252
|
+
* param (it pins literal text) and less than a fully static one, so it sits between them. */
|
|
253
|
+
const SPECIFICITY: Readonly<Record<RoutePatternSegment["kind"], number>> = {
|
|
254
|
+
static: 4,
|
|
255
|
+
mixed: 3,
|
|
256
|
+
param: 2,
|
|
257
|
+
wildcard: 1,
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Total ordering for mixed segment shapes, shared by the trie and regex-based routers.
|
|
262
|
+
*
|
|
263
|
+
* Code-unit comparison, never `localeCompare`: this exists so the server and the browser agree on which
|
|
264
|
+
* of two equally-weighted patterns wins, and `localeCompare` answers by the RUNTIME's locale - a server
|
|
265
|
+
* and a visitor in different locales would order the same pair differently, which is the divergence
|
|
266
|
+
* being prevented.
|
|
267
|
+
*/
|
|
268
|
+
export function compareMixedPartsSpecificity(
|
|
269
|
+
left: readonly MixedPart[],
|
|
270
|
+
right: readonly MixedPart[],
|
|
271
|
+
): number {
|
|
272
|
+
const literalWeight = (parts: readonly MixedPart[]): number =>
|
|
273
|
+
parts.reduce((sum, part) => (part.t === "lit" ? sum + part.v.length : sum), 0)
|
|
274
|
+
const order = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0)
|
|
275
|
+
|
|
276
|
+
const weightDifference = literalWeight(right) - literalWeight(left)
|
|
277
|
+
if (weightDifference !== 0) return weightDifference
|
|
278
|
+
|
|
279
|
+
const length = Math.max(left.length, right.length)
|
|
280
|
+
for (let i = 0; i < length; i++) {
|
|
281
|
+
const a = left[i]
|
|
282
|
+
const b = right[i]
|
|
283
|
+
if (a === undefined || b === undefined) return b === undefined ? -1 : 1
|
|
284
|
+
if (a.t !== b.t) return a.t === "lit" ? -1 : 1
|
|
285
|
+
if (a.t === "lit" && b.t === "lit" && a.v !== b.v) {
|
|
286
|
+
return a.v.length === b.v.length ? order(a.v, b.v) : b.v.length - a.v.length
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return order(mixedSegmentSource(left), mixedSegmentSource(right))
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Core precedence: static > mixed > param > wildcard at the first differing segment, independent of
|
|
294
|
+
* registration order. */
|
|
128
295
|
export function compareRoutePatternSpecificity(
|
|
129
296
|
left: CompiledRoutePattern,
|
|
130
297
|
right: CompiledRoutePattern,
|
|
@@ -134,9 +301,13 @@ export function compareRoutePatternSpecificity(
|
|
|
134
301
|
const a = left.segments[i]
|
|
135
302
|
const b = right.segments[i]
|
|
136
303
|
if (a === undefined || b === undefined) return b === undefined ? -1 : 1
|
|
137
|
-
const aWeight = a.kind
|
|
138
|
-
const bWeight = b.kind
|
|
304
|
+
const aWeight = SPECIFICITY[a.kind]
|
|
305
|
+
const bWeight = SPECIFICITY[b.kind]
|
|
139
306
|
if (aWeight !== bWeight) return bWeight - aWeight
|
|
307
|
+
if (a.kind === "mixed" && b.kind === "mixed") {
|
|
308
|
+
const mixed = compareMixedPartsSpecificity(a.parts, b.parts)
|
|
309
|
+
if (mixed !== 0) return mixed
|
|
310
|
+
}
|
|
140
311
|
}
|
|
141
312
|
return 0
|
|
142
313
|
}
|
package/src/router/router.ts
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import { RouteConfigError } from "../errors.ts"
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
type CompiledRoutePattern,
|
|
4
|
+
compareMixedPartsSpecificity,
|
|
5
|
+
compileRoutePattern,
|
|
6
|
+
type MixedPart,
|
|
7
|
+
mixedSegmentSource,
|
|
8
|
+
} from "./pattern.ts"
|
|
3
9
|
|
|
4
10
|
/** HTTP methods the router accepts. */
|
|
5
11
|
export const METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"] as const
|
|
@@ -68,16 +74,43 @@ interface RouteNode<T> {
|
|
|
68
74
|
readonly staticChildren: Map<string, RouteNode<T>>
|
|
69
75
|
/** Single `:param` child, if any. */
|
|
70
76
|
paramChild: RouteNode<T> | undefined
|
|
77
|
+
/**
|
|
78
|
+
* Part-literal-part-param children (`:key.txt`), ordered by specificity.
|
|
79
|
+
*
|
|
80
|
+
* Unlike `paramChild` there can legitimately be SEVERAL at one level - `/:id.txt` and `/:id.json`
|
|
81
|
+
* are different shapes, not a conflict - so this is a list rather than a slot, and it must be tried
|
|
82
|
+
* in specificity order rather than registration order.
|
|
83
|
+
*
|
|
84
|
+
* Left `undefined` unless a mixed route is registered, so an app that never uses one pays nothing:
|
|
85
|
+
* no allocation, and one `undefined` check on the match path.
|
|
86
|
+
*/
|
|
87
|
+
mixedChildren: MixedChild<T>[] | undefined
|
|
71
88
|
/** Catch-all `*` child, if any — always terminal. */
|
|
72
89
|
wildcardChild: RouteNode<T> | undefined
|
|
73
90
|
/** Set once this node terminates one or more routes. */
|
|
74
91
|
terminal: Terminal<T> | undefined
|
|
75
92
|
}
|
|
76
93
|
|
|
94
|
+
interface MixedChild<T> {
|
|
95
|
+
/** Anchored matcher for ONE segment, with a capture group per parameter. */
|
|
96
|
+
readonly regex: RegExp
|
|
97
|
+
/**
|
|
98
|
+
* How many values this segment pushes. A mixed segment pushes N, not one, so a failed branch must
|
|
99
|
+
* pop exactly N to keep `paramValues` aligned with `paramNames`.
|
|
100
|
+
*/
|
|
101
|
+
readonly arity: number
|
|
102
|
+
/** Parsed shape used by the same total specificity comparator as the browser router. */
|
|
103
|
+
readonly parts: readonly MixedPart[]
|
|
104
|
+
readonly node: RouteNode<T>
|
|
105
|
+
/** The pattern source, so re-registering the same shape reuses its node. */
|
|
106
|
+
readonly source: string
|
|
107
|
+
}
|
|
108
|
+
|
|
77
109
|
function createNode<T>(): RouteNode<T> {
|
|
78
110
|
return {
|
|
79
111
|
staticChildren: new Map(),
|
|
80
112
|
paramChild: undefined,
|
|
113
|
+
mixedChildren: undefined,
|
|
81
114
|
wildcardChild: undefined,
|
|
82
115
|
terminal: undefined,
|
|
83
116
|
}
|
|
@@ -123,6 +156,28 @@ function matchNode<T>(
|
|
|
123
156
|
}
|
|
124
157
|
}
|
|
125
158
|
|
|
159
|
+
// Mixed sits between static and param: it pins literal text, so it is more specific than a bare
|
|
160
|
+
// `:param`, and less than an exact segment. Guarded on `undefined` so a mixed-free route table
|
|
161
|
+
// never enters this branch.
|
|
162
|
+
const mixedChildren = node.mixedChildren
|
|
163
|
+
if (mixedChildren !== undefined && seg.length > 0) {
|
|
164
|
+
for (const child of mixedChildren) {
|
|
165
|
+
const matched = child.regex.exec(seg)
|
|
166
|
+
if (matched === null) continue
|
|
167
|
+
// Push left-to-right, matching the order `paramNames` was built in.
|
|
168
|
+
for (let i = 1; i <= child.arity; i++) paramValues.push(matched[i] as string)
|
|
169
|
+
if (isLast) {
|
|
170
|
+
if (child.node.terminal !== undefined) return child.node.terminal
|
|
171
|
+
} else {
|
|
172
|
+
const found = matchNode(child.node, path, end + 1, len, paramValues)
|
|
173
|
+
if (found !== undefined) return found
|
|
174
|
+
}
|
|
175
|
+
// Backtrack: a mixed segment pushed `arity` values, not one, so unwind exactly that many or
|
|
176
|
+
// every later param reads a value belonging to an abandoned branch.
|
|
177
|
+
for (let i = 0; i < child.arity; i++) paramValues.pop()
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
126
181
|
const paramChild = node.paramChild
|
|
127
182
|
// An empty segment (`/users//posts`) never matches a `:param` — matching would hand the handler
|
|
128
183
|
// `id: ""` and downstream code a `WHERE id = ''` class of bug. Peers (Hono, Elysia, Next) 404
|
|
@@ -250,6 +305,25 @@ export class Router<T> {
|
|
|
250
305
|
} else if (segment.kind === "wildcard") {
|
|
251
306
|
node.wildcardChild ??= createNode<T>()
|
|
252
307
|
node = node.wildcardChild
|
|
308
|
+
} else if (segment.kind === "mixed") {
|
|
309
|
+
const source = mixedSegmentSource(segment.parts)
|
|
310
|
+
node.mixedChildren ??= []
|
|
311
|
+
let child = node.mixedChildren.find((entry) => entry.source === source)
|
|
312
|
+
if (child === undefined) {
|
|
313
|
+
child = {
|
|
314
|
+
regex: new RegExp(`^${source}$`),
|
|
315
|
+
arity: segment.parts.reduce((n, part) => (part.t === "param" ? n + 1 : n), 0),
|
|
316
|
+
parts: segment.parts,
|
|
317
|
+
node: createNode<T>(),
|
|
318
|
+
source,
|
|
319
|
+
}
|
|
320
|
+
node.mixedChildren.push(child)
|
|
321
|
+
// Most literal text first. Registration order must not decide which of `/:id.txt` and
|
|
322
|
+
// `/:id.json` wins, and re-sorting on insert (a boot-time cost) keeps the match path a
|
|
323
|
+
// straight scan.
|
|
324
|
+
node.mixedChildren.sort((a, b) => compareMixedPartsSpecificity(a.parts, b.parts))
|
|
325
|
+
}
|
|
326
|
+
node = child.node
|
|
253
327
|
} else {
|
|
254
328
|
let child = node.staticChildren.get(segment.value)
|
|
255
329
|
if (child === undefined) {
|
|
@@ -21,6 +21,12 @@ import {
|
|
|
21
21
|
validIdempotencyKey,
|
|
22
22
|
validIdempotencyNamespace,
|
|
23
23
|
} from "../idempotency.ts"
|
|
24
|
+
import {
|
|
25
|
+
beginRequestEffectTracking,
|
|
26
|
+
markEffect,
|
|
27
|
+
requestEffectEvidence,
|
|
28
|
+
requestIsSafeToRetry,
|
|
29
|
+
} from "../internal/effect-execution.ts"
|
|
24
30
|
import { readBoundedBytes } from "./body.ts"
|
|
25
31
|
import type { Platform, RouteSchema } from "./context.ts"
|
|
26
32
|
import { jsonError, urlPartsOf } from "./http.ts"
|
|
@@ -53,6 +59,7 @@ export interface IdempotencyHost {
|
|
|
53
59
|
|
|
54
60
|
/** The injected idempotency implementation the kernel calls through when the plugin is installed. */
|
|
55
61
|
export interface IdempotencyRuntime {
|
|
62
|
+
readonly trackEffect: (context: object, committed: boolean) => void
|
|
56
63
|
/** Resolve a route's declared idempotency into a pinned store + defaults, or `undefined` when off. */
|
|
57
64
|
resolve(
|
|
58
65
|
schema: RouteSchema | undefined,
|
|
@@ -97,9 +104,6 @@ export function idempotency(options?: IdempotencyPluginOptions): IdentityPlugin
|
|
|
97
104
|
return Object.assign(apply, { pluginName: "nifra:idempotency" }) as IdentityPlugin
|
|
98
105
|
}
|
|
99
106
|
|
|
100
|
-
const RESPONSE_2XX_LOWER = 200
|
|
101
|
-
const RESPONSE_2XX_UPPER = 300
|
|
102
|
-
|
|
103
107
|
/**
|
|
104
108
|
* Build an idempotency runtime. `store` is the app-wide default backing routes that declare
|
|
105
109
|
* `schema.idempotency` without their own `store`; when omitted, a shared in-process
|
|
@@ -115,6 +119,9 @@ export function createIdempotencyRuntime(options?: IdempotencyPluginOptions): Id
|
|
|
115
119
|
}
|
|
116
120
|
|
|
117
121
|
return {
|
|
122
|
+
trackEffect(context, committed) {
|
|
123
|
+
markEffect(context, committed)
|
|
124
|
+
},
|
|
118
125
|
resolve(schema, authenticated, maxBodyBytes) {
|
|
119
126
|
const config = schema?.idempotency
|
|
120
127
|
if (config === undefined) return undefined
|
|
@@ -234,52 +241,79 @@ export function createIdempotencyRuntime(options?: IdempotencyPluginOptions): Id
|
|
|
234
241
|
if (read.bytes.byteLength > 0)
|
|
235
242
|
bufferedInit.body = read.bytes as NonNullable<RequestInit["body"]>
|
|
236
243
|
const buffered = new Request(req.url, bufferedInit)
|
|
244
|
+
beginRequestEffectTracking(buffered)
|
|
237
245
|
let response: Response
|
|
238
246
|
try {
|
|
239
247
|
response = await host.runLanes(buffered, platform, entry, params, search)
|
|
240
248
|
} catch (err) {
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
if (response.status >= RESPONSE_2XX_LOWER && response.status < RESPONSE_2XX_UPPER) {
|
|
248
|
-
let storedResponse: Awaited<ReturnType<typeof serializeResponse>>
|
|
249
|
-
try {
|
|
250
|
-
storedResponse = await serializeResponse(response, { maxBytes: config.maxResponseBytes })
|
|
251
|
-
} catch (error) {
|
|
252
|
-
if (!(error instanceof IdempotencyResponseTooLargeError)) throw error
|
|
253
|
-
// The effect may already have happened, so never abandon and permit a duplicate execution.
|
|
254
|
-
// Commit a small terminal response under the winning key and return that same response now.
|
|
255
|
-
response = jsonError(507, "idempotency_response_too_large")
|
|
256
|
-
try {
|
|
257
|
-
storedResponse = await serializeResponse(response, {
|
|
258
|
-
maxBytes: config.maxResponseBytes,
|
|
259
|
-
})
|
|
260
|
-
} catch (terminalError) {
|
|
261
|
-
if (!(terminalError instanceof IdempotencyResponseTooLargeError)) throw terminalError
|
|
262
|
-
// Even an intentionally tiny bound must remain truthful. An empty 507 preserves terminal
|
|
263
|
-
// status + replay safety without silently storing more bytes than the route permits.
|
|
264
|
-
response = new Response(null, { status: 507 })
|
|
265
|
-
storedResponse = await serializeResponse(response, {
|
|
266
|
-
maxBytes: config.maxResponseBytes,
|
|
267
|
-
})
|
|
268
|
-
}
|
|
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
|
|
269
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 })
|
|
270
259
|
const completed = await config.store.complete({
|
|
271
260
|
namespace,
|
|
272
261
|
key,
|
|
273
262
|
reservation,
|
|
274
|
-
response:
|
|
263
|
+
response: await serializeResponse(response, { maxBytes: config.maxResponseBytes }),
|
|
275
264
|
})
|
|
276
265
|
if (!completed) {
|
|
277
266
|
return wrapResponse(
|
|
278
267
|
jsonError(503, "idempotency_reservation_lost", { "Retry-After": "1" }),
|
|
279
268
|
)
|
|
280
269
|
}
|
|
281
|
-
|
|
282
|
-
|
|
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" }))
|
|
283
317
|
}
|
|
284
318
|
return wrapResponse(response)
|
|
285
319
|
},
|
package/src/server/respond.ts
CHANGED
package/src/server/server.ts
CHANGED
|
@@ -6,12 +6,17 @@ import {
|
|
|
6
6
|
NIFRA_DEADLINE_HEADER,
|
|
7
7
|
type RequestBudget,
|
|
8
8
|
} from "../budget.ts"
|
|
9
|
+
import type { EffectLifecycleObserver } from "../effect-lifecycle.ts"
|
|
9
10
|
import { FrameworkError, RouteConfigError } from "../errors.ts"
|
|
10
11
|
import {
|
|
12
|
+
type AroundCapabilityOptions,
|
|
11
13
|
CAPABILITY_GUARD,
|
|
14
|
+
type CapabilityInterceptor,
|
|
12
15
|
type CapabilityUseEvent,
|
|
13
16
|
createCapabilityGuard,
|
|
17
|
+
DEFAULT_CAPABILITY_INTERCEPTOR_TIMEOUT_MS,
|
|
14
18
|
normalizeRouteCapabilities,
|
|
19
|
+
type RegisteredCapabilityInterceptor,
|
|
15
20
|
} from "../internal/capability-runtime.ts"
|
|
16
21
|
import {
|
|
17
22
|
type AssuranceDeclaration,
|
|
@@ -517,6 +522,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
517
522
|
/** Capacity-admission gate; `undefined` = off (the request path pays nothing). */
|
|
518
523
|
private readonly capacityGate: AdmissionController | undefined
|
|
519
524
|
private readonly onCapabilityUse: ((event: CapabilityUseEvent) => void) | undefined
|
|
525
|
+
private readonly capabilityInterceptors: RegisteredCapabilityInterceptor[]
|
|
526
|
+
private readonly capabilityObservers: EffectLifecycleObserver[]
|
|
520
527
|
/** The installed effect-ledger runtime (owns the sink + per-route resolution + settle), or
|
|
521
528
|
* `undefined` when the effect-ledger plugin is not installed. */
|
|
522
529
|
private effectLedgerRuntime: EffectLedgerRuntime | undefined
|
|
@@ -579,6 +586,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
579
586
|
this.gracefulSignals = options.gracefulSignals ?? false
|
|
580
587
|
this.capacityGate = options.admission
|
|
581
588
|
this.onCapabilityUse = options.onCapabilityUse
|
|
589
|
+
this.capabilityInterceptors = []
|
|
590
|
+
this.capabilityObservers = []
|
|
582
591
|
// The effect-ledger runtime is installed by `.use(effectLedger())`; a bare app never imports it, so
|
|
583
592
|
// the ledger machinery tree-shakes out. Capability-declaring routes simply carry no ledger without it.
|
|
584
593
|
this.effectLedgerRuntime = undefined
|
|
@@ -659,6 +668,40 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
659
668
|
return this
|
|
660
669
|
}
|
|
661
670
|
|
|
671
|
+
/**
|
|
672
|
+
* Asynchronously admit each subsequent `executeCapability()` call before its owned effect runs.
|
|
673
|
+
* Interceptors receive token-only metadata plus an abort signal and must call `next()` exactly once
|
|
674
|
+
* to admit. Returning without `next()`, timing out, aborting, or throwing fails the effect closed.
|
|
675
|
+
*/
|
|
676
|
+
aroundCapability(
|
|
677
|
+
interceptor: CapabilityInterceptor,
|
|
678
|
+
options: AroundCapabilityOptions = {},
|
|
679
|
+
): this {
|
|
680
|
+
this.assertConfigurable("aroundCapability()")
|
|
681
|
+
if (typeof interceptor !== "function") {
|
|
682
|
+
throw new TypeError("aroundCapability interceptor must be a function")
|
|
683
|
+
}
|
|
684
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_CAPABILITY_INTERCEPTOR_TIMEOUT_MS
|
|
685
|
+
if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0) {
|
|
686
|
+
throw new RangeError("aroundCapability timeoutMs must be a positive safe integer")
|
|
687
|
+
}
|
|
688
|
+
// JS timers wrap larger delays to ~1ms, which would unexpectedly deny every effect.
|
|
689
|
+
this.capabilityInterceptors.push(
|
|
690
|
+
Object.freeze({ interceptor, timeoutMs: Math.min(timeoutMs, 2_147_483_647) }),
|
|
691
|
+
)
|
|
692
|
+
return this
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/** Observe token-only admission/execution lifecycle events for subsequent capability routes. */
|
|
696
|
+
observeCapability(observer: EffectLifecycleObserver): this {
|
|
697
|
+
this.assertConfigurable("observeCapability()")
|
|
698
|
+
if (typeof observer !== "function") {
|
|
699
|
+
throw new TypeError("observeCapability observer must be a function")
|
|
700
|
+
}
|
|
701
|
+
this.capabilityObservers.push(observer)
|
|
702
|
+
return this
|
|
703
|
+
}
|
|
704
|
+
|
|
662
705
|
/** Transform the handler's result before it is serialized. Order-scoped. */
|
|
663
706
|
afterHandle(fn: (result: unknown, context: Context & Ctx) => MaybePromise<unknown>): this {
|
|
664
707
|
this.assertConfigurable("afterHandle()")
|
|
@@ -1120,6 +1163,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1120
1163
|
method,
|
|
1121
1164
|
path,
|
|
1122
1165
|
this.onCapabilityUse,
|
|
1166
|
+
this.idempotencyRuntime?.trackEffect,
|
|
1167
|
+
Object.freeze([...this.capabilityInterceptors]),
|
|
1168
|
+
Object.freeze([...this.capabilityObservers]),
|
|
1123
1169
|
)
|
|
1124
1170
|
}
|
|
1125
1171
|
const hasDecorations = Reflect.ownKeys(routeDecorations).length > 0
|