@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.
Files changed (112) hide show
  1. package/README.md +27 -0
  2. package/dist/capabilities.d.ts +54 -2
  3. package/dist/capabilities.d.ts.map +1 -1
  4. package/dist/capabilities.js +240 -5
  5. package/dist/capabilities.js.map +1 -1
  6. package/dist/durable-adapters.d.ts +241 -0
  7. package/dist/durable-adapters.d.ts.map +1 -0
  8. package/dist/durable-adapters.js +697 -0
  9. package/dist/durable-adapters.js.map +1 -0
  10. package/dist/durable-execution.d.ts +349 -0
  11. package/dist/durable-execution.d.ts.map +1 -0
  12. package/dist/durable-execution.js +1173 -0
  13. package/dist/durable-execution.js.map +1 -0
  14. package/dist/effect-lifecycle.d.ts +42 -0
  15. package/dist/effect-lifecycle.d.ts.map +1 -0
  16. package/dist/effect-lifecycle.js +66 -0
  17. package/dist/effect-lifecycle.js.map +1 -0
  18. package/dist/effect-scope.d.ts +75 -0
  19. package/dist/effect-scope.d.ts.map +1 -0
  20. package/dist/effect-scope.js +177 -0
  21. package/dist/effect-scope.js.map +1 -0
  22. package/dist/idempotency-plugin.d.ts +5 -0
  23. package/dist/idempotency-plugin.d.ts.map +1 -1
  24. package/dist/idempotency-plugin.js +8 -0
  25. package/dist/idempotency-plugin.js.map +1 -1
  26. package/dist/index.d.ts +1 -1
  27. package/dist/index.js +1 -1
  28. package/dist/internal/capability-runtime.d.ts +62 -1
  29. package/dist/internal/capability-runtime.d.ts.map +1 -1
  30. package/dist/internal/capability-runtime.js +11 -2
  31. package/dist/internal/capability-runtime.js.map +1 -1
  32. package/dist/internal/effect-execution.d.ts +16 -0
  33. package/dist/internal/effect-execution.d.ts.map +1 -0
  34. package/dist/internal/effect-execution.js +50 -0
  35. package/dist/internal/effect-execution.js.map +1 -0
  36. package/dist/internal/safe-equal.d.ts +20 -0
  37. package/dist/internal/safe-equal.d.ts.map +1 -0
  38. package/dist/internal/safe-equal.js +31 -0
  39. package/dist/internal/safe-equal.js.map +1 -0
  40. package/dist/ledger.d.ts +14 -6
  41. package/dist/ledger.d.ts.map +1 -1
  42. package/dist/ledger.js +31 -5
  43. package/dist/ledger.js.map +1 -1
  44. package/dist/reconciliation-worker.d.ts +108 -0
  45. package/dist/reconciliation-worker.d.ts.map +1 -0
  46. package/dist/reconciliation-worker.js +239 -0
  47. package/dist/reconciliation-worker.js.map +1 -0
  48. package/dist/router/pattern.d.ts +26 -1
  49. package/dist/router/pattern.d.ts.map +1 -1
  50. package/dist/router/pattern.js +164 -8
  51. package/dist/router/pattern.js.map +1 -1
  52. package/dist/router/router.d.ts.map +1 -1
  53. package/dist/router/router.js +49 -1
  54. package/dist/router/router.js.map +1 -1
  55. package/dist/server/idempotency-lane.d.ts +1 -0
  56. package/dist/server/idempotency-lane.d.ts.map +1 -1
  57. package/dist/server/idempotency-lane.js +61 -37
  58. package/dist/server/idempotency-lane.js.map +1 -1
  59. package/dist/server/respond.d.ts.map +1 -1
  60. package/dist/server/respond.js.map +1 -1
  61. package/dist/server/server.d.ts +12 -0
  62. package/dist/server/server.d.ts.map +1 -1
  63. package/dist/server/server.js +33 -2
  64. package/dist/server/server.js.map +1 -1
  65. package/dist/server/transport-lane.d.ts +28 -0
  66. package/dist/server/transport-lane.d.ts.map +1 -0
  67. package/dist/server/transport-lane.js +106 -0
  68. package/dist/server/transport-lane.js.map +1 -0
  69. package/dist/server/websocket.d.ts +6 -0
  70. package/dist/server/websocket.d.ts.map +1 -1
  71. package/dist/server/websocket.js +10 -1
  72. package/dist/server/websocket.js.map +1 -1
  73. package/dist/transport-codec-rich.d.ts +8 -0
  74. package/dist/transport-codec-rich.d.ts.map +1 -0
  75. package/dist/transport-codec-rich.js +14 -0
  76. package/dist/transport-codec-rich.js.map +1 -0
  77. package/dist/transport-codec.d.ts +31 -0
  78. package/dist/transport-codec.d.ts.map +1 -0
  79. package/dist/transport-codec.js +214 -0
  80. package/dist/transport-codec.js.map +1 -0
  81. package/dist/transport-plugin.d.ts +3 -0
  82. package/dist/transport-plugin.d.ts.map +1 -0
  83. package/dist/transport-plugin.js +3 -0
  84. package/dist/transport-plugin.js.map +1 -0
  85. package/dist/wire.d.ts +58 -0
  86. package/dist/wire.d.ts.map +1 -0
  87. package/dist/wire.js +445 -0
  88. package/dist/wire.js.map +1 -0
  89. package/package.json +46 -1
  90. package/src/capabilities.ts +306 -5
  91. package/src/durable-adapters.ts +1039 -0
  92. package/src/durable-execution.ts +1676 -0
  93. package/src/effect-lifecycle.ts +120 -0
  94. package/src/effect-scope.ts +267 -0
  95. package/src/idempotency-plugin.ts +10 -0
  96. package/src/index.ts +1 -1
  97. package/src/internal/capability-runtime.ts +87 -1
  98. package/src/internal/effect-execution.ts +70 -0
  99. package/src/internal/safe-equal.ts +27 -0
  100. package/src/ledger.ts +45 -11
  101. package/src/reconciliation-worker.ts +372 -0
  102. package/src/router/pattern.ts +179 -8
  103. package/src/router/router.ts +75 -1
  104. package/src/server/idempotency-lane.ts +68 -34
  105. package/src/server/respond.ts +1 -0
  106. package/src/server/server.ts +46 -0
  107. package/src/server/transport-lane.ts +141 -0
  108. package/src/server/websocket.ts +15 -1
  109. package/src/transport-codec-rich.ts +20 -0
  110. package/src/transport-codec.ts +284 -0
  111. package/src/transport-plugin.ts +2 -0
  112. package/src/wire.ts +474 -0
@@ -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
- if (value.charCodeAt(0) === 58 /* : */) {
75
- const name = value.slice(1)
76
- if (!validParamName(name)) {
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
- /** Core precedence: static > param > wildcard at the first differing segment, independent of order. */
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 === "static" ? 3 : a.kind === "param" ? 2 : 1
138
- const bWeight = b.kind === "static" ? 3 : b.kind === "param" ? 2 : 1
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
  }
@@ -1,5 +1,11 @@
1
1
  import { RouteConfigError } from "../errors.ts"
2
- import { type CompiledRoutePattern, compileRoutePattern } from "./pattern.ts"
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
- // The lanes never throw (errors resolve to a 500 Response), but stay fail-safe: release the
242
- // reservation so a retry isn't wedged, then re-throw.
243
- await config.store.abandon({ namespace, key, reservation })
244
- throw err
245
- }
246
- // Cache only successful responses; release the key on any error so the client can retry.
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: storedResponse,
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
- } else {
282
- await config.store.abandon({ namespace, key, reservation })
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
  },
@@ -3,6 +3,7 @@
3
3
  * responders, the fast JSON path, and cookie append. Imports only the runtime-core primitives + the
4
4
  * spine types, so the wire-response format lives in one module distinct from the request engine.
5
5
  */
6
+
6
7
  import {
7
8
  CONTEXT_SET,
8
9
  EMPTY_RESPONSE_CONTROLS,
@@ -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