@nifrajs/core 3.3.0 → 4.0.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 (331) hide show
  1. package/README.md +26 -0
  2. package/dist/assurance.d.ts +7 -0
  3. package/dist/assurance.d.ts.map +1 -1
  4. package/dist/assurance.js +39 -3
  5. package/dist/assurance.js.map +1 -1
  6. package/dist/capabilities.d.ts +20 -2
  7. package/dist/capabilities.d.ts.map +1 -1
  8. package/dist/capabilities.js +47 -15
  9. package/dist/capabilities.js.map +1 -1
  10. package/dist/diff.d.ts +6 -1
  11. package/dist/diff.d.ts.map +1 -1
  12. package/dist/diff.js +36 -2
  13. package/dist/diff.js.map +1 -1
  14. package/dist/durable-adapters.d.ts.map +1 -1
  15. package/dist/durable-adapters.js +16 -3
  16. package/dist/durable-adapters.js.map +1 -1
  17. package/dist/durable-effect.d.ts.map +1 -1
  18. package/dist/durable-effect.js +6 -3
  19. package/dist/durable-effect.js.map +1 -1
  20. package/dist/edge-kit.d.ts +2 -0
  21. package/dist/edge-kit.d.ts.map +1 -1
  22. package/dist/edge-kit.js +2 -0
  23. package/dist/edge-kit.js.map +1 -1
  24. package/dist/effect-lifecycle.d.ts.map +1 -1
  25. package/dist/effect-lifecycle.js +2 -1
  26. package/dist/effect-lifecycle.js.map +1 -1
  27. package/dist/effect-scope.d.ts.map +1 -1
  28. package/dist/effect-scope.js +1 -1
  29. package/dist/effect-scope.js.map +1 -1
  30. package/dist/errors.d.ts +1 -1
  31. package/dist/errors.d.ts.map +1 -1
  32. package/dist/errors.js.map +1 -1
  33. package/dist/evidence.d.ts +36 -1
  34. package/dist/evidence.d.ts.map +1 -1
  35. package/dist/evidence.js +219 -13
  36. package/dist/evidence.js.map +1 -1
  37. package/dist/idempotency.d.ts +31 -0
  38. package/dist/idempotency.d.ts.map +1 -1
  39. package/dist/idempotency.js +48 -9
  40. package/dist/idempotency.js.map +1 -1
  41. package/dist/index.d.ts +9 -6
  42. package/dist/index.d.ts.map +1 -1
  43. package/dist/index.js +6 -3
  44. package/dist/index.js.map +1 -1
  45. package/dist/internal/code-unit-order.d.ts +7 -0
  46. package/dist/internal/code-unit-order.d.ts.map +1 -0
  47. package/dist/internal/code-unit-order.js +9 -0
  48. package/dist/internal/code-unit-order.js.map +1 -0
  49. package/dist/internal/durable-saga-protocol.d.ts.map +1 -1
  50. package/dist/internal/durable-saga-protocol.js +2 -0
  51. package/dist/internal/durable-saga-protocol.js.map +1 -1
  52. package/dist/internal/route-catalog.d.ts +6 -6
  53. package/dist/internal/route-catalog.d.ts.map +1 -1
  54. package/dist/internal/route-catalog.js +20 -18
  55. package/dist/internal/route-catalog.js.map +1 -1
  56. package/dist/internal/route-compiler.d.ts +7 -6
  57. package/dist/internal/route-compiler.d.ts.map +1 -1
  58. package/dist/internal/route-compiler.js +36 -4
  59. package/dist/internal/route-compiler.js.map +1 -1
  60. package/dist/internal/route-execution.d.ts +7 -3
  61. package/dist/internal/route-execution.d.ts.map +1 -1
  62. package/dist/internal/route-execution.js.map +1 -1
  63. package/dist/internal/route-lanes.d.ts +1 -0
  64. package/dist/internal/route-lanes.d.ts.map +1 -1
  65. package/dist/internal/route-lanes.js +34 -29
  66. package/dist/internal/route-lanes.js.map +1 -1
  67. package/dist/internal/route-program.d.ts +8 -2
  68. package/dist/internal/route-program.d.ts.map +1 -1
  69. package/dist/internal/route-program.js +222 -20
  70. package/dist/internal/route-program.js.map +1 -1
  71. package/dist/ledger.d.ts.map +1 -1
  72. package/dist/ledger.js +8 -5
  73. package/dist/ledger.js.map +1 -1
  74. package/dist/manifest.d.ts +1 -1
  75. package/dist/manifest.d.ts.map +1 -1
  76. package/dist/manifest.js +52 -46
  77. package/dist/manifest.js.map +1 -1
  78. package/dist/mount.d.ts +36 -0
  79. package/dist/mount.d.ts.map +1 -1
  80. package/dist/mount.js +32 -0
  81. package/dist/mount.js.map +1 -1
  82. package/dist/reflection.d.ts +26 -0
  83. package/dist/reflection.d.ts.map +1 -1
  84. package/dist/reflection.js +65 -1
  85. package/dist/reflection.js.map +1 -1
  86. package/dist/response-observer.d.ts +1 -1
  87. package/dist/response-observer.d.ts.map +1 -1
  88. package/dist/response-observer.js.map +1 -1
  89. package/dist/router/overlap.d.ts +23 -0
  90. package/dist/router/overlap.d.ts.map +1 -0
  91. package/dist/router/overlap.js +224 -0
  92. package/dist/router/overlap.js.map +1 -0
  93. package/dist/router/pattern.d.ts +117 -2
  94. package/dist/router/pattern.d.ts.map +1 -1
  95. package/dist/router/pattern.js +283 -23
  96. package/dist/router/pattern.js.map +1 -1
  97. package/dist/router/router.d.ts +18 -4
  98. package/dist/router/router.d.ts.map +1 -1
  99. package/dist/router/router.js +51 -44
  100. package/dist/router/router.js.map +1 -1
  101. package/dist/server/auth.d.ts +53 -0
  102. package/dist/server/auth.d.ts.map +1 -0
  103. package/dist/server/auth.js +9 -0
  104. package/dist/server/auth.js.map +1 -0
  105. package/dist/server/bare-error-lane.d.ts +12 -7
  106. package/dist/server/bare-error-lane.d.ts.map +1 -1
  107. package/dist/server/bare-error-lane.js +13 -11
  108. package/dist/server/bare-error-lane.js.map +1 -1
  109. package/dist/server/body-lane.d.ts +20 -2
  110. package/dist/server/body-lane.d.ts.map +1 -1
  111. package/dist/server/body-lane.js +26 -7
  112. package/dist/server/body-lane.js.map +1 -1
  113. package/dist/server/body-parser.d.ts +68 -0
  114. package/dist/server/body-parser.d.ts.map +1 -0
  115. package/dist/server/body-parser.js +172 -0
  116. package/dist/server/body-parser.js.map +1 -0
  117. package/dist/server/body.d.ts.map +1 -1
  118. package/dist/server/body.js +62 -7
  119. package/dist/server/body.js.map +1 -1
  120. package/dist/server/client-ip.d.ts +8 -0
  121. package/dist/server/client-ip.d.ts.map +1 -1
  122. package/dist/server/client-ip.js +8 -0
  123. package/dist/server/client-ip.js.map +1 -1
  124. package/dist/server/context.d.ts +71 -6
  125. package/dist/server/context.d.ts.map +1 -1
  126. package/dist/server/contract.d.ts +15 -5
  127. package/dist/server/contract.d.ts.map +1 -1
  128. package/dist/server/contract.js +25 -4
  129. package/dist/server/contract.js.map +1 -1
  130. package/dist/server/cookies.d.ts +4 -1
  131. package/dist/server/cookies.d.ts.map +1 -1
  132. package/dist/server/cookies.js +40 -2
  133. package/dist/server/cookies.js.map +1 -1
  134. package/dist/server/core-copies.d.ts +2 -0
  135. package/dist/server/core-copies.d.ts.map +1 -0
  136. package/dist/server/core-copies.js +20 -0
  137. package/dist/server/core-copies.js.map +1 -0
  138. package/dist/server/edge.d.ts.map +1 -1
  139. package/dist/server/edge.js +2 -0
  140. package/dist/server/edge.js.map +1 -1
  141. package/dist/server/http.d.ts +11 -0
  142. package/dist/server/http.d.ts.map +1 -1
  143. package/dist/server/http.js +36 -0
  144. package/dist/server/http.js.map +1 -1
  145. package/dist/server/idempotency-lane.d.ts +6 -0
  146. package/dist/server/idempotency-lane.d.ts.map +1 -1
  147. package/dist/server/idempotency-lane.js +130 -73
  148. package/dist/server/idempotency-lane.js.map +1 -1
  149. package/dist/server/install.d.ts +4 -0
  150. package/dist/server/install.d.ts.map +1 -1
  151. package/dist/server/install.js +4 -0
  152. package/dist/server/install.js.map +1 -1
  153. package/dist/server/methods.d.ts +45 -0
  154. package/dist/server/methods.d.ts.map +1 -0
  155. package/dist/server/methods.js +37 -0
  156. package/dist/server/methods.js.map +1 -0
  157. package/dist/server/multipart.d.ts +63 -0
  158. package/dist/server/multipart.d.ts.map +1 -0
  159. package/dist/server/multipart.js +374 -0
  160. package/dist/server/multipart.js.map +1 -0
  161. package/dist/server/node-outcome-hook.d.ts +20 -0
  162. package/dist/server/node-outcome-hook.d.ts.map +1 -1
  163. package/dist/server/node-outcome-hook.js +15 -6
  164. package/dist/server/node-outcome-hook.js.map +1 -1
  165. package/dist/server/not-found-answer.d.ts +40 -0
  166. package/dist/server/not-found-answer.d.ts.map +1 -0
  167. package/dist/server/not-found-answer.js +56 -0
  168. package/dist/server/not-found-answer.js.map +1 -0
  169. package/dist/server/not-found.d.ts +23 -0
  170. package/dist/server/not-found.d.ts.map +1 -0
  171. package/dist/server/not-found.js +81 -0
  172. package/dist/server/not-found.js.map +1 -0
  173. package/dist/server/plugin.d.ts +26 -1
  174. package/dist/server/plugin.d.ts.map +1 -1
  175. package/dist/server/plugin.js.map +1 -1
  176. package/dist/server/proto-guard.d.ts +18 -5
  177. package/dist/server/proto-guard.d.ts.map +1 -1
  178. package/dist/server/proto-guard.js +114 -9
  179. package/dist/server/proto-guard.js.map +1 -1
  180. package/dist/server/query.d.ts +7 -0
  181. package/dist/server/query.d.ts.map +1 -1
  182. package/dist/server/query.js +47 -0
  183. package/dist/server/query.js.map +1 -1
  184. package/dist/server/registry.d.ts +13 -2
  185. package/dist/server/registry.d.ts.map +1 -1
  186. package/dist/server/request-context.d.ts +11 -0
  187. package/dist/server/request-context.d.ts.map +1 -1
  188. package/dist/server/request-context.js +33 -19
  189. package/dist/server/request-context.js.map +1 -1
  190. package/dist/server/request-lineage.d.ts +8 -0
  191. package/dist/server/request-lineage.d.ts.map +1 -0
  192. package/dist/server/request-lineage.js +14 -0
  193. package/dist/server/request-lineage.js.map +1 -0
  194. package/dist/server/request-target.d.ts +31 -0
  195. package/dist/server/request-target.d.ts.map +1 -0
  196. package/dist/server/request-target.js +44 -0
  197. package/dist/server/request-target.js.map +1 -0
  198. package/dist/server/respond.d.ts +1 -1
  199. package/dist/server/respond.d.ts.map +1 -1
  200. package/dist/server/respond.js +16 -4
  201. package/dist/server/respond.js.map +1 -1
  202. package/dist/server/response-contract-lane.d.ts +19 -1
  203. package/dist/server/response-contract-lane.d.ts.map +1 -1
  204. package/dist/server/response-contract-lane.js +59 -14
  205. package/dist/server/response-contract-lane.js.map +1 -1
  206. package/dist/server/response-observer-runtime.d.ts +9 -5
  207. package/dist/server/response-observer-runtime.d.ts.map +1 -1
  208. package/dist/server/response-observer-runtime.js +10 -6
  209. package/dist/server/response-observer-runtime.js.map +1 -1
  210. package/dist/server/runtime-core.d.ts +2 -0
  211. package/dist/server/runtime-core.d.ts.map +1 -1
  212. package/dist/server/runtime-core.js.map +1 -1
  213. package/dist/server/server-types.d.ts +55 -3
  214. package/dist/server/server-types.d.ts.map +1 -1
  215. package/dist/server/server.d.ts +194 -19
  216. package/dist/server/server.d.ts.map +1 -1
  217. package/dist/server/server.js +1342 -274
  218. package/dist/server/server.js.map +1 -1
  219. package/dist/server/sse.d.ts.map +1 -1
  220. package/dist/server/sse.js +10 -7
  221. package/dist/server/sse.js.map +1 -1
  222. package/dist/server/static-headers.d.ts +3 -0
  223. package/dist/server/static-headers.d.ts.map +1 -1
  224. package/dist/server/static-headers.js +19 -1
  225. package/dist/server/static-headers.js.map +1 -1
  226. package/dist/server/transport-lane.d.ts +8 -0
  227. package/dist/server/transport-lane.d.ts.map +1 -1
  228. package/dist/server/transport-lane.js +9 -8
  229. package/dist/server/transport-lane.js.map +1 -1
  230. package/dist/server/validation.d.ts.map +1 -1
  231. package/dist/server/validation.js +5 -1
  232. package/dist/server/validation.js.map +1 -1
  233. package/dist/server/websocket.d.ts +10 -3
  234. package/dist/server/websocket.d.ts.map +1 -1
  235. package/dist/server/websocket.js +7 -4
  236. package/dist/server/websocket.js.map +1 -1
  237. package/dist/server/ws-bun.d.ts +2 -0
  238. package/dist/server/ws-bun.d.ts.map +1 -1
  239. package/dist/server/ws-bun.js +6 -3
  240. package/dist/server/ws-bun.js.map +1 -1
  241. package/dist/server/ws-hook.d.ts +2 -1
  242. package/dist/server/ws-hook.d.ts.map +1 -1
  243. package/dist/server.d.ts +8 -5
  244. package/dist/server.d.ts.map +1 -1
  245. package/dist/server.js +5 -2
  246. package/dist/server.js.map +1 -1
  247. package/dist/single-copy-register.js +5 -0
  248. package/dist/single-copy-register.js.map +1 -1
  249. package/dist/single-copy.d.ts +46 -3
  250. package/dist/single-copy.d.ts.map +1 -1
  251. package/dist/single-copy.js +125 -12
  252. package/dist/single-copy.js.map +1 -1
  253. package/dist/tool-contract.d.ts +23 -0
  254. package/dist/tool-contract.d.ts.map +1 -1
  255. package/dist/tool-contract.js +37 -3
  256. package/dist/tool-contract.js.map +1 -1
  257. package/dist/transport-codec.d.ts.map +1 -1
  258. package/dist/transport-codec.js +2 -1
  259. package/dist/transport-codec.js.map +1 -1
  260. package/package.json +27 -2
  261. package/src/assurance.ts +52 -2
  262. package/src/capabilities.ts +81 -22
  263. package/src/diff.ts +54 -7
  264. package/src/durable-adapters.ts +20 -3
  265. package/src/durable-effect.ts +10 -3
  266. package/src/edge-kit.ts +7 -0
  267. package/src/effect-lifecycle.ts +5 -1
  268. package/src/effect-scope.ts +2 -1
  269. package/src/errors.ts +3 -0
  270. package/src/evidence.ts +302 -12
  271. package/src/idempotency.ts +80 -9
  272. package/src/index.ts +41 -3
  273. package/src/internal/code-unit-order.ts +8 -0
  274. package/src/internal/durable-saga-protocol.ts +2 -0
  275. package/src/internal/route-catalog.ts +19 -18
  276. package/src/internal/route-compiler.ts +62 -13
  277. package/src/internal/route-execution.ts +13 -3
  278. package/src/internal/route-lanes.ts +37 -28
  279. package/src/internal/route-program.ts +434 -20
  280. package/src/ledger.ts +11 -5
  281. package/src/manifest.ts +52 -46
  282. package/src/mount.ts +62 -0
  283. package/src/reflection.ts +84 -1
  284. package/src/response-observer.ts +1 -0
  285. package/src/router/overlap.ts +258 -0
  286. package/src/router/pattern.ts +349 -25
  287. package/src/router/router.ts +76 -51
  288. package/src/server/auth.ts +75 -0
  289. package/src/server/bare-error-lane.ts +12 -11
  290. package/src/server/body-lane.ts +46 -6
  291. package/src/server/body-parser.ts +241 -0
  292. package/src/server/body.ts +82 -9
  293. package/src/server/client-ip.ts +9 -0
  294. package/src/server/context.ts +219 -12
  295. package/src/server/contract.ts +54 -9
  296. package/src/server/cookies.ts +34 -2
  297. package/src/server/core-copies.ts +21 -0
  298. package/src/server/edge.ts +2 -0
  299. package/src/server/http.ts +34 -0
  300. package/src/server/idempotency-lane.ts +147 -72
  301. package/src/server/install.ts +6 -0
  302. package/src/server/methods.ts +143 -0
  303. package/src/server/multipart.ts +424 -0
  304. package/src/server/node-outcome-hook.ts +40 -7
  305. package/src/server/not-found-answer.ts +109 -0
  306. package/src/server/not-found.ts +121 -0
  307. package/src/server/plugin.ts +45 -1
  308. package/src/server/proto-guard.ts +128 -9
  309. package/src/server/query.ts +49 -0
  310. package/src/server/registry.ts +17 -2
  311. package/src/server/request-context.ts +44 -16
  312. package/src/server/request-lineage.ts +15 -0
  313. package/src/server/request-target.ts +46 -0
  314. package/src/server/respond.ts +14 -3
  315. package/src/server/response-contract-lane.ts +97 -16
  316. package/src/server/response-observer-runtime.ts +37 -8
  317. package/src/server/runtime-core.ts +2 -0
  318. package/src/server/server-types.ts +61 -2
  319. package/src/server/server.ts +1798 -355
  320. package/src/server/sse.ts +10 -8
  321. package/src/server/static-headers.ts +22 -1
  322. package/src/server/transport-lane.ts +17 -9
  323. package/src/server/validation.ts +7 -1
  324. package/src/server/websocket.ts +23 -9
  325. package/src/server/ws-bun.ts +8 -3
  326. package/src/server/ws-hook.ts +2 -1
  327. package/src/server.ts +39 -2
  328. package/src/single-copy-register.ts +5 -0
  329. package/src/single-copy.ts +160 -12
  330. package/src/tool-contract.ts +66 -3
  331. package/src/transport-codec.ts +2 -1
package/src/manifest.ts CHANGED
@@ -12,10 +12,9 @@ import {
12
12
  diffRouteSnapshots,
13
13
  type RouteChange,
14
14
  type RouteSnapshotSchema,
15
- snapshotRoutes,
16
15
  } from "./diff.ts"
17
- import type { ProjectEvidenceSnapshot } from "./evidence.ts"
18
- import { reflectRoutes } from "./reflection.ts"
16
+ import { type ProjectEvidenceSnapshot, snapshotProjectEvidence } from "./evidence.ts"
17
+ import { codeUnitOrder } from "./internal/code-unit-order.ts"
19
18
 
20
19
  export interface NifraManifestAssurance {
21
20
  readonly rule?: string
@@ -190,25 +189,28 @@ export async function buildNifraManifest(input: BuildNifraManifestInput): Promis
190
189
  if (input.evidence?.capabilities !== undefined && !input.evidence.capabilities.ok) {
191
190
  throw new Error("nifra manifest: refusing to emit failing capability assurance")
192
191
  }
192
+ const evidence =
193
+ input.evidence ??
194
+ snapshotProjectEvidence(input.source, {
195
+ ...(input.assurance === undefined ? {} : { assurance: input.assurance }),
196
+ ...(input.capabilities === undefined ? {} : { capabilities: input.capabilities }),
197
+ })
193
198
  const reflected = new Map(
194
- (input.evidence?.routes ?? reflectRoutes(input.source)).map((route) => [
195
- routeKey(route.method, route.path),
196
- route,
197
- ]),
199
+ evidence.routes.map((route) => [routeKey(route.method, route.path), route]),
198
200
  )
199
201
  const assured = new Map(
200
- (input.evidence?.assurance?.routes ?? input.assurance?.routes ?? []).map((route) => [
202
+ (evidence.assurance?.routes ?? input.assurance?.routes ?? []).map((route) => [
201
203
  routeKey(route.method, route.path),
202
204
  route,
203
205
  ]),
204
206
  )
205
207
  const capable = new Map(
206
- (input.evidence?.capabilities?.routes ?? input.capabilities?.routes ?? []).map((route) => [
208
+ (evidence.capabilities?.routes ?? input.capabilities?.routes ?? []).map((route) => [
207
209
  routeKey(route.method, route.path),
208
210
  route,
209
211
  ]),
210
212
  )
211
- const routes = (input.evidence?.routes ?? snapshotRoutes(input.source))
213
+ const routes = evidence.routes
212
214
  .map((route): NifraManifestRoute => {
213
215
  const key = routeKey(route.method, route.path)
214
216
  const reflection = reflected.get(key)
@@ -225,7 +227,7 @@ export async function buildNifraManifest(input: BuildNifraManifestInput): Promis
225
227
  evidence: Object.freeze(
226
228
  [...assurance.evidence]
227
229
  .map((item) => ({ id: item.id, source: item.source }))
228
- .sort((a, b) => a.id.localeCompare(b.id) || a.source.localeCompare(b.source)),
230
+ .sort((a, b) => codeUnitOrder(a.id, b.id) || codeUnitOrder(a.source, b.source)),
229
231
  ),
230
232
  },
231
233
  }
@@ -245,7 +247,7 @@ export async function buildNifraManifest(input: BuildNifraManifestInput): Promis
245
247
  : {}),
246
248
  }
247
249
  })
248
- .sort((a, b) => a.path.localeCompare(b.path) || a.method.localeCompare(b.method))
250
+ .sort((a, b) => codeUnitOrder(a.path, b.path) || codeUnitOrder(a.method, b.method))
249
251
  const body = Object.freeze({ manifestVersion: 1 as const, routes: Object.freeze(routes) })
250
252
  return Object.freeze({ ...body, contentHash: await sha256Hex(canonicalManifest(body)) })
251
253
  }
@@ -461,44 +463,48 @@ export function diffNifraManifests(before: NifraManifest, after: NifraManifest):
461
463
  before.routes.map((route) => [routeKey(route.method, route.path), route]),
462
464
  )
463
465
  for (const route of after.routes) {
464
- const old = previous.get(routeKey(route.method, route.path))
465
- if (old === undefined) continue
466
+ const existing = previous.get(routeKey(route.method, route.path))
466
467
  const base = { method: route.method, path: route.path } as const
467
468
 
468
- const oldRule = old.assurance?.rule
469
- const newRule = route.assurance?.rule
470
- if (oldRule !== newRule) {
471
- changes.push({
472
- ...base,
473
- section: "assurance",
474
- severity: "breaking",
475
- message: `assurance rule changed from ${oldRule ?? "unclassified"} to ${newRule ?? "unclassified"}`,
476
- })
477
- }
478
- const oldEvidence = (old.assurance?.evidence ?? [])
479
- .map((item) => `${item.id}@${item.source}`)
480
- .sort()
481
- const newEvidence = (route.assurance?.evidence ?? [])
482
- .map((item) => `${item.id}@${item.source}`)
483
- .sort()
484
- const assuranceDelta = listChanges(oldEvidence, newEvidence)
485
- for (const value of assuranceDelta.added) {
486
- changes.push({
487
- ...base,
488
- section: "assurance",
489
- severity: "compatible",
490
- message: `assurance evidence added ${value}`,
491
- })
492
- }
493
- for (const value of assuranceDelta.removed) {
494
- changes.push({
495
- ...base,
496
- section: "assurance",
497
- severity: "breaking",
498
- message: `assurance evidence removed ${value}`,
499
- })
469
+ if (existing !== undefined) {
470
+ const oldRule = existing.assurance?.rule
471
+ const newRule = route.assurance?.rule
472
+ if (oldRule !== newRule) {
473
+ changes.push({
474
+ ...base,
475
+ section: "assurance",
476
+ severity: "breaking",
477
+ message: `assurance rule changed from ${oldRule ?? "unclassified"} to ${newRule ?? "unclassified"}`,
478
+ })
479
+ }
480
+ const oldEvidence = (existing.assurance?.evidence ?? [])
481
+ .map((item) => `${item.id}@${item.source}`)
482
+ .sort()
483
+ const newEvidence = (route.assurance?.evidence ?? [])
484
+ .map((item) => `${item.id}@${item.source}`)
485
+ .sort()
486
+ const assuranceDelta = listChanges(oldEvidence, newEvidence)
487
+ for (const value of assuranceDelta.added) {
488
+ changes.push({
489
+ ...base,
490
+ section: "assurance",
491
+ severity: "compatible",
492
+ message: `assurance evidence added ${value}`,
493
+ })
494
+ }
495
+ for (const value of assuranceDelta.removed) {
496
+ changes.push({
497
+ ...base,
498
+ section: "assurance",
499
+ severity: "breaking",
500
+ message: `assurance evidence removed ${value}`,
501
+ })
502
+ }
500
503
  }
501
504
 
505
+ // A new route is diffed against an empty one: the capabilities it declares and the data it returns
506
+ // expand risk as surely as the same change to a route that already existed.
507
+ const old: Partial<NifraManifestRoute> = existing ?? {}
502
508
  for (const field of ["declared", "evidenced", "unproven"] as const) {
503
509
  const delta = listChanges(old.capabilities?.[field] ?? [], route.capabilities?.[field] ?? [])
504
510
  for (const value of delta.added) {
package/src/mount.ts CHANGED
@@ -7,18 +7,80 @@
7
7
  * in-process mount.
8
8
  */
9
9
 
10
+ import type { ProjectEvidenceSnapshot } from "./evidence.ts"
10
11
  import type { Platform } from "./server/context.ts"
12
+ import type { WebSocketUpgradeOutcome } from "./server/websocket.ts"
11
13
 
12
14
  /** Global symbol so independently bundled copies of core/client/web still agree on the mount seam. */
13
15
  export const NIFRA_BACKEND_MOUNT = Symbol.for("@nifrajs/backend-mount")
14
16
 
17
+ /** Optional symbol-keyed upgrade seam carried by an in-process client mount. */
18
+ export const NIFRA_BACKEND_WS_MOUNT = Symbol.for("@nifrajs/backend-ws-mount")
19
+
20
+ /** Internal runtime-provider seam used when an in-process WebSocket backend is mounted in Bun. */
21
+ export const NIFRA_BACKEND_WS_RUNTIME = Symbol.for("@nifrajs/backend-ws-runtime")
22
+
23
+ /** Optional offline assurance/evidence seam for composed applications. */
24
+ export const NIFRA_BACKEND_EVIDENCE = Symbol.for("@nifrajs/backend-evidence")
25
+
26
+ /**
27
+ * Optional request-scoping seam carried by an in-process typed client. Called with one request's
28
+ * {@link Platform}, it returns a view of the same typed client whose calls dispatch with that platform,
29
+ * so a backend reached from an SSR loader sees the page visitor's `c.clientIp`, `c.env` and
30
+ * `c.waitUntil`. The view carries platform fields only - never the page request's headers.
31
+ */
32
+ export const NIFRA_BACKEND_BIND_PLATFORM = Symbol.for("@nifrajs/backend-bind-platform")
33
+
34
+ export { NIFRA_PLATFORM_CLIENT_IP_DERIVED } from "./server/client-ip.ts"
35
+
15
36
  /** Dispatch one already-materialized request into a backend with its outer runtime platform context. */
16
37
  export type BackendMountHandler<Env = unknown> = (
17
38
  request: Request,
18
39
  platform?: Platform<Env>,
19
40
  ) => Response | Promise<Response>
20
41
 
42
+ /** Adapter-neutral WebSocket upgrade resolver for a mounted backend. */
43
+ export type BackendWebSocketMountHandler<Env = unknown> = (
44
+ request: Request,
45
+ platform?: Platform<Env>,
46
+ ) => WebSocketUpgradeOutcome | Promise<WebSocketUpgradeOutcome>
47
+
48
+ /** Opaque provider kept structural so public mount types do not expose the WS runtime internals. */
49
+ export type BackendWebSocketRuntimeProvider = () => unknown
50
+
51
+ /** Returns a view of an in-process typed client bound to one request's platform. */
52
+ export type BackendPlatformBinder<Env = unknown> = (platform: Platform<Env>) => unknown
53
+
54
+ /** Produces token-only route/evidence facts; it is never called from request dispatch. */
55
+ export type BackendEvidenceProvider = () =>
56
+ | ProjectEvidenceSnapshot
57
+ | Promise<ProjectEvidenceSnapshot>
58
+
59
+ /**
60
+ * The path of every pre-route `mount()` on a nifra server, as the server matches it (`"/"` for a root
61
+ * mount). Such a mount answers every request under its path before the app's own routes run, and
62
+ * `fallbackOn: 404` only moves on to the next mount, so a route the app declares there is unreachable.
63
+ * Reads the server's own mount table, as `reflectMounts` does; a value that is not a nifra server
64
+ * yields `[]`. Never called from request dispatch.
65
+ */
66
+ export function preRouteMountPaths(app: unknown): readonly string[] {
67
+ const mounts = (app as { readonly fetchMounts?: unknown } | null | undefined)?.fetchMounts
68
+ if (!Array.isArray(mounts)) return []
69
+ const paths: string[] = []
70
+ for (const mount of mounts as ReadonlyArray<{
71
+ readonly path?: unknown
72
+ readonly beforeRoutes?: unknown
73
+ } | null>) {
74
+ if (mount?.beforeRoutes === true && typeof mount.path === "string") paths.push(mount.path)
75
+ }
76
+ return paths
77
+ }
78
+
21
79
  /** Structural mount capability exposed by an in-process typed client. */
22
80
  export interface BackendMount<Env = unknown> {
23
81
  readonly [NIFRA_BACKEND_MOUNT]: BackendMountHandler<Env>
82
+ readonly [NIFRA_BACKEND_WS_MOUNT]?: BackendWebSocketMountHandler<Env>
83
+ readonly [NIFRA_BACKEND_WS_RUNTIME]?: BackendWebSocketRuntimeProvider
84
+ readonly [NIFRA_BACKEND_EVIDENCE]?: BackendEvidenceProvider
85
+ readonly [NIFRA_BACKEND_BIND_PLATFORM]?: BackendPlatformBinder<Env>
24
86
  }
package/src/reflection.ts CHANGED
@@ -18,6 +18,7 @@ import {
18
18
  evidenceProvenance,
19
19
  validEvidence,
20
20
  } from "./internal/route-assurance.ts"
21
+ import { NIFRA_BACKEND_EVIDENCE } from "./mount.ts"
21
22
  import type { StandardSchemaV1 } from "./schema/standard.ts"
22
23
  import type { ToolAnnotations } from "./server/server.ts"
23
24
 
@@ -39,14 +40,25 @@ export interface SchemaReflection {
39
40
  readonly jsonSchema: JsonSchema | undefined
40
41
  /** Top-level object fields, or `undefined` when the JSON Schema is absent/non-object. */
41
42
  readonly fields: readonly ReflectedSchemaField[] | undefined
43
+ /**
44
+ * The media types a body schema reads through a parser of its own (`bodyParser`), beyond the
45
+ * JSON and urlencoded bodies every body schema reads. Absent when it declares none.
46
+ */
47
+ readonly mediaTypes?: readonly string[]
42
48
  }
43
49
 
44
50
  export interface ReflectedRouteSchema {
51
+ /** Explicit external-protocol mode; the handler owns raw request parsing. */
52
+ readonly wire?: "raw"
53
+ /** Validation/authentication ordering selected for this route. */
54
+ readonly validationOrder?: "validate-before-auth" | "auth-before-validation"
45
55
  /** Effective transport body policy; surfaced so upload/streaming exemptions are auditable. */
46
56
  readonly bodyLimit?: number | "unlimited"
47
57
  readonly bodyLimitReason?: string
48
58
  /** Request-header schema; names are normalized to lower-case at runtime. */
49
59
  readonly headers?: SchemaReflection
60
+ /** Request-cookie schema, validated against the parsed `Cookie` header. */
61
+ readonly cookies?: SchemaReflection
50
62
  readonly body?: SchemaReflection
51
63
  readonly query?: SchemaReflection
52
64
  /** Path-params schema - constraints (uuid format, integer min/max) declared via `params: t.object(…)`. */
@@ -61,6 +73,8 @@ export interface ReflectedRoute {
61
73
  readonly method: string
62
74
  readonly path: string
63
75
  readonly schema?: ReflectedRouteSchema
76
+ /** Runtime response-contract mode, when a response-contract plugin covered the route. */
77
+ readonly responseContract?: "warn" | "enforce"
64
78
  readonly assurance?: readonly AssuranceEvidence[]
65
79
  readonly capabilities?: readonly string[]
66
80
  /** Set when the route is a declared dynamic route family (a runtime-resolved template like
@@ -146,6 +160,24 @@ const fieldsOf = (schema: JsonSchema | undefined): readonly ReflectedSchemaField
146
160
  return fields
147
161
  }
148
162
 
163
+ // The brand a body schema carries its own reader under. Spelled here rather than imported so that
164
+ // reflection does not pull the body lane into a bundle that only describes routes.
165
+ const BODY_READER = Symbol.for("nifra.body.schemaReader")
166
+
167
+ const mediaTypesOf = (value: unknown): readonly string[] | undefined => {
168
+ if (value === null || (typeof value !== "object" && typeof value !== "function")) return undefined
169
+ try {
170
+ const reader = (value as Record<symbol, unknown>)[BODY_READER]
171
+ if (typeof reader !== "function") return undefined
172
+ const declared = (reader as { readonly mediaTypes?: unknown }).mediaTypes
173
+ if (!Array.isArray(declared)) return undefined
174
+ const types = declared.filter((type): type is string => typeof type === "string")
175
+ return types.length > 0 ? Object.freeze(types) : undefined
176
+ } catch {
177
+ return undefined
178
+ }
179
+ }
180
+
149
181
  /**
150
182
  * Reflect a Standard Schema, Nifra/TypeBox schema carrier, or raw JSON Schema. Never throws.
151
183
  * Validation-only schemas have `standard` but no `jsonSchema`; raw JSON Schema has the reverse.
@@ -153,7 +185,13 @@ const fieldsOf = (schema: JsonSchema | undefined): readonly ReflectedSchemaField
153
185
  export function reflectSchema(value: unknown): SchemaReflection {
154
186
  const standard = standardOf(value)
155
187
  const jsonSchema = jsonSchemaOf(value, standard)
156
- return { standard, jsonSchema, fields: fieldsOf(jsonSchema) }
188
+ const mediaTypes = mediaTypesOf(value)
189
+ return {
190
+ standard,
191
+ jsonSchema,
192
+ fields: fieldsOf(jsonSchema),
193
+ ...(mediaTypes !== undefined ? { mediaTypes } : {}),
194
+ }
157
195
  }
158
196
 
159
197
  const routeCandidates = (source: unknown): readonly unknown[] => {
@@ -179,6 +217,11 @@ const reflectedRouteSchema = (value: unknown): ReflectedRouteSchema | undefined
179
217
  }
180
218
  }
181
219
  return {
220
+ ...(schema.wire === "raw" ? { wire: "raw" as const } : {}),
221
+ ...(schema.validationOrder === "auth-before-validation" ||
222
+ schema.validationOrder === "validate-before-auth"
223
+ ? { validationOrder: schema.validationOrder }
224
+ : {}),
182
225
  ...(schema.bodyLimit === "unlimited" || typeof schema.bodyLimit === "number"
183
226
  ? { bodyLimit: schema.bodyLimit }
184
227
  : {}),
@@ -186,6 +229,7 @@ const reflectedRouteSchema = (value: unknown): ReflectedRouteSchema | undefined
186
229
  ? { bodyLimitReason: schema.bodyLimitReason }
187
230
  : {}),
188
231
  ...(schema.headers !== undefined ? { headers: reflectSchema(schema.headers) } : {}),
232
+ ...(schema.cookies !== undefined ? { cookies: reflectSchema(schema.cookies) } : {}),
189
233
  ...(schema.body !== undefined ? { body: reflectSchema(schema.body) } : {}),
190
234
  ...(schema.query !== undefined ? { query: reflectSchema(schema.query) } : {}),
191
235
  ...(schema.params !== undefined ? { params: reflectSchema(schema.params) } : {}),
@@ -242,6 +286,9 @@ export function reflectRoutes(source: unknown): readonly ReflectedRoute[] {
242
286
  method: route.method.toUpperCase(),
243
287
  path: route.path,
244
288
  ...(schema !== undefined ? { schema } : {}),
289
+ ...(route.responseContract === "warn" || route.responseContract === "enforce"
290
+ ? { responseContract: route.responseContract }
291
+ : {}),
245
292
  ...(assurance !== undefined ? { assurance } : {}),
246
293
  ...(capabilities.length > 0 ? { capabilities } : {}),
247
294
  ...(route.family === true ? { family: true } : {}),
@@ -251,3 +298,39 @@ export function reflectRoutes(source: unknown): readonly ReflectedRoute[] {
251
298
  }
252
299
  return reflected
253
300
  }
301
+
302
+ /** A mounted child (`mount()`, `mountFetch()`) whose routes route reflection cannot see. */
303
+ export interface ReflectedMount {
304
+ /** The mount prefix with `/*`: the child serves every path under it. */
305
+ readonly path: string
306
+ /** Why the child is not analyzed, as declared by `opaque` on the mount. */
307
+ readonly opaque?: string
308
+ }
309
+
310
+ /**
311
+ * The mounts on an app that route reflection cannot see into, sorted by path. A mount whose app
312
+ * publishes composed evidence (the API `createWebApp` mounts) is left out: its routes reach
313
+ * reflection through that evidence. Anything that is not a nifra server yields an empty list.
314
+ */
315
+ export function reflectMounts(source: unknown): readonly ReflectedMount[] {
316
+ // `fetchMounts` is the server's own mount table, read here rather than exposed through a method so
317
+ // apps that never reflect pay nothing for it.
318
+ const mounts = recordOf(source)?.fetchMounts
319
+ if (!Array.isArray(mounts)) return []
320
+ const reflected: ReflectedMount[] = []
321
+ for (const candidate of mounts) {
322
+ const mount = recordOf(candidate)
323
+ if (typeof mount?.path !== "string") continue
324
+ const app = mount.app as { readonly [NIFRA_BACKEND_EVIDENCE]?: unknown } | undefined
325
+ if (typeof app?.[NIFRA_BACKEND_EVIDENCE] === "function") continue
326
+ // Checked here, at analysis time, so serving pays nothing: a blank reason is no reason.
327
+ const opaque = typeof mount.opaque === "string" ? mount.opaque.trim() || undefined : undefined
328
+ reflected.push(
329
+ Object.freeze({
330
+ path: mount.path === "/" ? "/*" : `${mount.path}/*`,
331
+ ...(opaque !== undefined ? { opaque } : {}),
332
+ }),
333
+ )
334
+ }
335
+ return reflected.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))
336
+ }
@@ -6,6 +6,7 @@ import {
6
6
  import type { AnyServer } from "./server/server.ts"
7
7
 
8
8
  export type {
9
+ NodeResponseBodyHook,
9
10
  ResponseBodyHook,
10
11
  ResponseHeadersHook,
11
12
  ResponseObserverMethods,
@@ -0,0 +1,258 @@
1
+ import {
2
+ type CompiledRoutePattern,
3
+ compileRoutePattern,
4
+ expandOptionalParams,
5
+ type MixedPart,
6
+ type ParamConstraint,
7
+ } from "./pattern.ts"
8
+
9
+ type Token =
10
+ | { readonly kind: "char"; readonly value: string }
11
+ | { readonly kind: "param"; readonly c?: ParamConstraint }
12
+ | { readonly kind: "wildcard" }
13
+
14
+ interface State {
15
+ readonly index: number
16
+ readonly active?: "param" | "wildcard"
17
+ /**
18
+ * Inside a parameter constrained to a character class: how many characters it has taken. Once the
19
+ * class has no upper bound the count stops at the lower one, where it no longer changes anything.
20
+ */
21
+ readonly taken?: number
22
+ /** Inside a parameter constrained to a list of values: what is left of each value still possible. */
23
+ readonly live?: readonly string[]
24
+ }
25
+
26
+ interface ProductNode {
27
+ readonly left: State
28
+ readonly right: State
29
+ readonly previous?: string
30
+ readonly character?: string
31
+ }
32
+
33
+ /** Hard budget for the offline route-language intersection. Never use this on the request path. */
34
+ export const ROUTE_PATTERN_OVERLAP_MAX_STATES = 100_000
35
+ const ROUTE_PATTERN_OVERLAP_MAX_LENGTH = 16_384
36
+
37
+ export class RoutePatternOverlapLimitError extends Error {
38
+ readonly code = "ROUTE_OVERLAP_LIMIT"
39
+
40
+ constructor() {
41
+ super("route overlap analysis exceeded its safety budget")
42
+ this.name = "RoutePatternOverlapLimitError"
43
+ }
44
+ }
45
+
46
+ const stateKey = (state: State): string =>
47
+ `${state.index}:${state.active ?? "none"}:${state.taken ?? ""}:${state.live?.join("|") ?? ""}`
48
+ const productKey = (left: State, right: State): string => `${stateKey(left)}|${stateKey(right)}`
49
+
50
+ function pushLiteral(tokens: Token[], value: string): void {
51
+ for (const character of value) tokens.push({ kind: "char", value: character })
52
+ }
53
+
54
+ function pushMixed(tokens: Token[], parts: readonly MixedPart[]): void {
55
+ for (const part of parts) {
56
+ if (part.t === "lit") pushLiteral(tokens, part.v)
57
+ else tokens.push(part.c === undefined ? { kind: "param" } : { kind: "param", c: part.c })
58
+ }
59
+ }
60
+
61
+ function tokensOf(pattern: CompiledRoutePattern): readonly Token[] {
62
+ const tokens: Token[] = [{ kind: "char", value: "/" }]
63
+ pattern.segments.forEach((segment, index) => {
64
+ if (index > 0) tokens.push({ kind: "char", value: "/" })
65
+ if (segment.kind === "static") pushLiteral(tokens, segment.value)
66
+ else if (segment.kind === "param") tokens.push({ kind: "param" })
67
+ else if (segment.kind === "wildcard") tokens.push({ kind: "wildcard" })
68
+ else pushMixed(tokens, segment.parts)
69
+ })
70
+ return tokens
71
+ }
72
+
73
+ function epsilon(state: State, tokens: readonly Token[]): State | undefined {
74
+ if (state.active === undefined) return undefined
75
+ // A constrained parameter ends only where its constraint is met: a whole value from the list, or
76
+ // at least the class's lower bound of characters.
77
+ if (state.live?.includes("") === false) return undefined
78
+ const token = tokens[state.index]
79
+ if (state.taken !== undefined && token?.kind === "param" && state.taken < token.c!.min) {
80
+ return undefined
81
+ }
82
+ return { index: state.index + 1 }
83
+ }
84
+
85
+ function advance(state: State, character: string, tokens: readonly Token[]): State | undefined {
86
+ if (state.active === "wildcard") return state
87
+ const token = tokens[state.index]
88
+ if (token === undefined) return undefined
89
+ if (token.kind === "char") {
90
+ return token.value === character ? { index: state.index + 1 } : undefined
91
+ }
92
+ if (token.kind === "wildcard") return { index: state.index, active: "wildcard" }
93
+ if (character === "/") return undefined
94
+ const constraint = token.c
95
+ if (constraint === undefined) {
96
+ return state.active === "param" ? state : { index: state.index, active: "param" }
97
+ }
98
+ if (constraint.oneOf !== undefined) {
99
+ const live = (state.live ?? constraint.oneOf)
100
+ .filter((value) => value[0] === character)
101
+ .map((value) => value.slice(1))
102
+ return live.length === 0 ? undefined : { index: state.index, active: "param", live }
103
+ }
104
+ const taken = state.taken ?? 0
105
+ const code = character.charCodeAt(0)
106
+ if (taken >= constraint.max || constraint.mask.charCodeAt(code) !== 49 /* 1 */) return undefined
107
+ return {
108
+ index: state.index,
109
+ active: "param",
110
+ taken: constraint.max === Infinity ? Math.min(taken + 1, constraint.min) : taken + 1,
111
+ }
112
+ }
113
+
114
+ /** The exact characters that can advance `state`, or `undefined` when it accepts a character class. */
115
+ function exactCharacters(state: State, tokens: readonly Token[]): readonly string[] | undefined {
116
+ if (state.active === "wildcard") return undefined
117
+ const token = tokens[state.index]
118
+ if (token === undefined) return []
119
+ if (token.kind === "char") return [token.value]
120
+ if (token.kind === "wildcard") return undefined
121
+ const values = state.live ?? token.c?.oneOf
122
+ return values === undefined
123
+ ? undefined
124
+ : [...new Set(values.flatMap((value) => (value === "" ? [] : [value[0]!])))]
125
+ }
126
+
127
+ // "a" first, so an unconstrained parameter is still witnessed by the letter it always was.
128
+ const CLASS_REPRESENTATIVES = ["a"]
129
+ for (let code = 33; code < 127; code++) {
130
+ if (code !== 47 /* / */ && code !== 97 /* a */) {
131
+ CLASS_REPRESENTATIVES.push(String.fromCharCode(code))
132
+ }
133
+ }
134
+
135
+ /**
136
+ * Return the finite representative alphabet for this product state. A global alphabet makes a
137
+ * route containing many literal characters quadratic in the number of states. At each state only
138
+ * the exact characters one side asks for can affect the next state, or - when both sides take a
139
+ * class of characters - one character that both classes hold: where a class leads does not depend on
140
+ * which of its characters was taken. Slash is additionally needed only when both sides are
141
+ * wildcards because it can move through a segment boundary that no parameter may consume.
142
+ */
143
+ function transitionCharacters(
144
+ left: State,
145
+ right: State,
146
+ leftTokens: readonly Token[],
147
+ rightTokens: readonly Token[],
148
+ ): readonly string[] {
149
+ const exact = exactCharacters(left, leftTokens) ?? exactCharacters(right, rightTokens)
150
+ if (exact !== undefined) return exact
151
+ const both = (character: string): boolean =>
152
+ advance(left, character, leftTokens) !== undefined &&
153
+ advance(right, character, rightTokens) !== undefined
154
+ const shared = CLASS_REPRESENTATIVES.find(both)
155
+ const characters = shared === undefined ? [] : [shared]
156
+ if (both("/")) characters.push("/")
157
+ return characters
158
+ }
159
+
160
+ function accepted(state: State, tokens: readonly Token[]): boolean {
161
+ return state.active === undefined && state.index === tokens.length
162
+ }
163
+
164
+ function witnessOf(nodes: ReadonlyMap<string, ProductNode>, key: string): string {
165
+ const characters: string[] = []
166
+ let current = key
167
+ while (true) {
168
+ const node = nodes.get(current)
169
+ if (node === undefined || node.previous === undefined) break
170
+ if (node.character !== undefined) characters.push(node.character)
171
+ current = node.previous
172
+ }
173
+ characters.reverse()
174
+ return characters.join("")
175
+ }
176
+
177
+ /**
178
+ * Return a deterministic path accepted by both patterns, or `undefined` when their path languages are
179
+ * disjoint. A pattern ending in optional params is every concrete path it serves, so `/users/:id?`
180
+ * overlaps `/users` as well as `/users/me`.
181
+ *
182
+ * This is a build/check-time NFA product, never a request-time operation. Route literals are the only
183
+ * input alphabet needed: a literal character is tried verbatim, while `a` is a representative for
184
+ * the unrestricted non-slash character class. No user route text is compiled as a regular expression.
185
+ *
186
+ * A constrained parameter is modelled as its constraint: a value from its list, or a run of its
187
+ * class within its bounds. In a segment that has several parameters the router is stricter than
188
+ * that - it places the literals first and tests the values afterwards - so for such a segment the
189
+ * answer errs towards reporting an overlap that no request can reach, never towards missing one.
190
+ * The state budget is one budget for the whole call, however many concrete paths the two sides have.
191
+ */
192
+ export function routePatternOverlap(left: string, right: string): string | undefined {
193
+ if (
194
+ left.length > ROUTE_PATTERN_OVERLAP_MAX_LENGTH ||
195
+ right.length > ROUTE_PATTERN_OVERLAP_MAX_LENGTH
196
+ )
197
+ throw new RoutePatternOverlapLimitError()
198
+ const rights = expandOptionalParams(right).map((form) => tokensOf(compileRoutePattern(form)))
199
+ const budget = { states: ROUTE_PATTERN_OVERLAP_MAX_STATES }
200
+ for (const form of expandOptionalParams(left)) {
201
+ const leftTokens = tokensOf(compileRoutePattern(form))
202
+ for (const rightTokens of rights) {
203
+ const witness = tokenOverlap(leftTokens, rightTokens, budget)
204
+ if (witness !== undefined) return witness
205
+ }
206
+ }
207
+ return undefined
208
+ }
209
+
210
+ function tokenOverlap(
211
+ leftTokens: readonly Token[],
212
+ rightTokens: readonly Token[],
213
+ budget: { states: number },
214
+ ): string | undefined {
215
+ const startLeft: State = { index: 0 }
216
+ const startRight: State = { index: 0 }
217
+ const start = productKey(startLeft, startRight)
218
+ if (budget.states-- <= 0) throw new RoutePatternOverlapLimitError()
219
+ const nodes = new Map<string, ProductNode>([[start, { left: startLeft, right: startRight }]])
220
+ const queue: string[] = [start]
221
+ let head = 0
222
+
223
+ const enqueue = (next: string, node: ProductNode): void => {
224
+ if (nodes.has(next)) return
225
+ if (budget.states-- <= 0) throw new RoutePatternOverlapLimitError()
226
+ nodes.set(next, node)
227
+ queue.push(next)
228
+ }
229
+
230
+ while (head < queue.length) {
231
+ const key = queue[head++]!
232
+ const node = nodes.get(key)!
233
+ if (accepted(node.left, leftTokens) && accepted(node.right, rightTokens)) {
234
+ return witnessOf(nodes, key)
235
+ }
236
+
237
+ const transitions: Array<{ readonly left: State; readonly right: State }> = []
238
+ const leftEpsilon = epsilon(node.left, leftTokens)
239
+ const rightEpsilon = epsilon(node.right, rightTokens)
240
+ if (leftEpsilon !== undefined) transitions.push({ left: leftEpsilon, right: node.right })
241
+ if (rightEpsilon !== undefined) transitions.push({ left: node.left, right: rightEpsilon })
242
+
243
+ for (const transition of transitions) {
244
+ const next = productKey(transition.left, transition.right)
245
+ enqueue(next, { ...transition, previous: key })
246
+ }
247
+
248
+ for (const character of transitionCharacters(node.left, node.right, leftTokens, rightTokens)) {
249
+ const nextLeft = advance(node.left, character, leftTokens)
250
+ const nextRight = advance(node.right, character, rightTokens)
251
+ if (nextLeft === undefined || nextRight === undefined) continue
252
+ const next = productKey(nextLeft, nextRight)
253
+ enqueue(next, { left: nextLeft, right: nextRight, previous: key, character })
254
+ }
255
+ }
256
+
257
+ return undefined
258
+ }