@xmachines/play-router 1.0.0-beta.3 → 1.0.0-beta.30
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/LICENSE +21 -0
- package/README.md +217 -54
- package/dist/base-route-map.d.ts +123 -0
- package/dist/base-route-map.d.ts.map +1 -0
- package/dist/base-route-map.js +172 -0
- package/dist/base-route-map.js.map +1 -0
- package/dist/build-tree.d.ts.map +1 -1
- package/dist/build-tree.js +6 -5
- package/dist/build-tree.js.map +1 -1
- package/dist/create-route-map-from-machine.d.ts +24 -0
- package/dist/create-route-map-from-machine.d.ts.map +1 -0
- package/dist/create-route-map-from-machine.js +32 -0
- package/dist/create-route-map-from-machine.js.map +1 -0
- package/dist/create-route-map-from-tree.d.ts +31 -0
- package/dist/create-route-map-from-tree.d.ts.map +1 -0
- package/dist/create-route-map-from-tree.js +42 -0
- package/dist/create-route-map-from-tree.js.map +1 -0
- package/dist/create-route-map.d.ts +21 -1
- package/dist/create-route-map.d.ts.map +1 -1
- package/dist/create-route-map.js +32 -22
- package/dist/create-route-map.js.map +1 -1
- package/dist/errors.d.ts +75 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +85 -0
- package/dist/errors.js.map +1 -0
- package/dist/extract-routes.d.ts +5 -31
- package/dist/extract-routes.d.ts.map +1 -1
- package/dist/extract-routes.js +70 -49
- package/dist/extract-routes.js.map +1 -1
- package/dist/find-route.d.ts +44 -0
- package/dist/find-route.d.ts.map +1 -0
- package/dist/find-route.js +98 -0
- package/dist/find-route.js.map +1 -0
- package/dist/index.d.ts +11 -50
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +15 -133
- package/dist/index.js.map +1 -1
- package/dist/machine-to-graph.d.ts +17 -0
- package/dist/machine-to-graph.d.ts.map +1 -0
- package/dist/machine-to-graph.js +115 -0
- package/dist/machine-to-graph.js.map +1 -0
- package/dist/query.d.ts +44 -1
- package/dist/query.d.ts.map +1 -1
- package/dist/query.js +80 -3
- package/dist/query.js.map +1 -1
- package/dist/router-bridge-base.d.ts +103 -19
- package/dist/router-bridge-base.d.ts.map +1 -1
- package/dist/router-bridge-base.js +179 -67
- package/dist/router-bridge-base.js.map +1 -1
- package/dist/router-sync.d.ts +87 -0
- package/dist/router-sync.d.ts.map +1 -0
- package/dist/router-sync.js +132 -0
- package/dist/router-sync.js.map +1 -0
- package/dist/types.d.ts +94 -20
- package/dist/types.d.ts.map +1 -1
- package/dist/url-pattern-utils.d.ts +60 -0
- package/dist/url-pattern-utils.d.ts.map +1 -0
- package/dist/url-pattern-utils.js +77 -0
- package/dist/url-pattern-utils.js.map +1 -0
- package/dist/validate-routes.d.ts +9 -9
- package/dist/validate-routes.d.ts.map +1 -1
- package/dist/validate-routes.js +12 -11
- package/dist/validate-routes.js.map +1 -1
- package/package.json +38 -12
- package/dist/connect-router.d.ts +0 -56
- package/dist/connect-router.d.ts.map +0 -1
- package/dist/connect-router.js +0 -119
- package/dist/connect-router.js.map +0 -1
- package/dist/crawl-machine.d.ts +0 -74
- package/dist/crawl-machine.d.ts.map +0 -1
- package/dist/crawl-machine.js +0 -95
- package/dist/crawl-machine.js.map +0 -1
- package/dist/create-browser-history.d.ts +0 -68
- package/dist/create-browser-history.d.ts.map +0 -1
- package/dist/create-browser-history.js +0 -94
- package/dist/create-browser-history.js.map +0 -1
- package/dist/create-router.d.ts +0 -73
- package/dist/create-router.d.ts.map +0 -1
- package/dist/create-router.js +0 -63
- package/dist/create-router.js.map +0 -1
- package/dist/extract-route.d.ts +0 -25
- package/dist/extract-route.d.ts.map +0 -1
- package/dist/extract-route.js +0 -63
- package/dist/extract-route.js.map +0 -1
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mikael Karon
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -2,17 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
**Route tree extraction from XState v5 state machines with routing patterns**
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Graph-based route extraction and bidirectional lookup enabling Actor Authority over navigation.
|
|
6
6
|
|
|
7
7
|
## Overview
|
|
8
8
|
|
|
9
|
-
`@xmachines/play-router` extracts route trees from XState state machines
|
|
9
|
+
`@xmachines/play-router` extracts route trees from XState state machines using `@statelyai/graph` — a typed, JSON-serializable graph library. It converts machines to a directed `Graph` with hierarchy, transition edges, and route metadata, then builds hierarchical `RouteTree` structures with bidirectional state ID ↔ path mapping.
|
|
10
10
|
|
|
11
11
|
It also exports `RouterBridgeBase`, the shared base class used by framework adapters to implement `RouterBridge` with consistent actor↔router synchronization behavior.
|
|
12
12
|
|
|
13
13
|
`RouterBridgeBase` is the policy point; framework adapters are thin ports that implement only framework-specific navigate/subscribe/unsubscribe behavior.
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
The low-level `connectRouter()` from `@xmachines/play-dom-router` uses the same router-to-actor event builder and route-map match helper as `RouterBridgeBase`, so pathname sanitization, state-id normalization, param extraction, and query extraction stay aligned across vanilla and framework adapters.
|
|
16
|
+
|
|
17
|
+
Per [Play RFC](../docs/rfc/play.md), this package implements:
|
|
16
18
|
|
|
17
19
|
- **Actor Authority (INV-01):** Routes derive from machine definitions, not external configuration
|
|
18
20
|
|
|
@@ -27,13 +29,32 @@ npm install @xmachines/play-router
|
|
|
27
29
|
|
|
28
30
|
**Peer dependencies:**
|
|
29
31
|
|
|
30
|
-
- `xstate` ^5.0.0
|
|
32
|
+
- `xstate` ^5.0.0 — State machine runtime
|
|
33
|
+
|
|
34
|
+
**URLPattern polyfill (Node.js < 24 / older browsers):**
|
|
35
|
+
|
|
36
|
+
`@xmachines/play-router` uses the [URLPattern API](https://developer.mozilla.org/en-US/docs/Web/API/URLPattern) for dynamic route matching. URLPattern is available natively on Node.js 24+ and modern browsers (Chrome 95+, Firefox 117+, Safari 16.4+).
|
|
37
|
+
|
|
38
|
+
On environments without native support, load a polyfill **before** importing this package:
|
|
39
|
+
|
|
40
|
+
```typescript
|
|
41
|
+
// Entry point — must run before any @xmachines/play-router import
|
|
42
|
+
import "urlpattern-polyfill";
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Install the polyfill:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
npm install urlpattern-polyfill
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
`urlpattern-polyfill` is declared as an optional peer dependency. Package managers will not install it automatically — consumers must install and load it when their runtime lacks native URLPattern support (Node.js < 24, older browsers).
|
|
31
52
|
|
|
32
53
|
## Quick Start
|
|
33
54
|
|
|
34
55
|
```typescript
|
|
35
56
|
import { createMachine } from "xstate";
|
|
36
|
-
import { extractMachineRoutes } from "@xmachines/play-router";
|
|
57
|
+
import { extractMachineRoutes, findRouteByPath } from "@xmachines/play-router";
|
|
37
58
|
|
|
38
59
|
// Route pattern (recommended)
|
|
39
60
|
const machine = createMachine({
|
|
@@ -65,11 +86,11 @@ const machine = createMachine({
|
|
|
65
86
|
const tree = extractMachineRoutes(machine);
|
|
66
87
|
|
|
67
88
|
// Bidirectional lookup
|
|
68
|
-
console.log(tree
|
|
69
|
-
console.log(tree.
|
|
89
|
+
console.log(findRouteByPath(tree, "/overview")); // RouteNode
|
|
90
|
+
console.log(tree.byStateId.get("overview")); // RouteNode
|
|
70
91
|
|
|
71
92
|
// Pattern matching for dynamic routes
|
|
72
|
-
const settingsRoute = tree
|
|
93
|
+
const settingsRoute = findRouteByPath(tree, "/settings/profile");
|
|
73
94
|
console.log(settingsRoute?.id); // "settings"
|
|
74
95
|
```
|
|
75
96
|
|
|
@@ -88,9 +109,7 @@ The demo demonstrates:
|
|
|
88
109
|
**Run the demo:**
|
|
89
110
|
|
|
90
111
|
```bash
|
|
91
|
-
|
|
92
|
-
npm install
|
|
93
|
-
npm run dev
|
|
112
|
+
npm run dev -w @xmachines/play-dom-router-demo
|
|
94
113
|
```
|
|
95
114
|
|
|
96
115
|
Open http://localhost:5174/ and explore:
|
|
@@ -159,6 +178,34 @@ Bridge teardown must be explicit and deterministic:
|
|
|
159
178
|
- `disconnect`/`dispose` must unwatch signal subscriptions and unhook router listeners.
|
|
160
179
|
- Do not rely on GC-only cleanup guidance.
|
|
161
180
|
- Infrastructure remains passive: bridges observe and forward intents, actors decide validity.
|
|
181
|
+
- `createBrowserHistory().destroy()` (from `@xmachines/play-dom-router`) is idempotent and restores shared `window.history` patches only after the last wrapper for that window is removed.
|
|
182
|
+
- **Note**: `createBrowserHistory()` mutates global `window.history` methods (`pushState` and `replaceState`) and coordinates wrappers with shared ref-count state. To reduce leakage, create one history wrapper per browser window at the application boundary and always pair it with `destroy()` during teardown.
|
|
183
|
+
|
|
184
|
+
## Bridge Sync Ordering
|
|
185
|
+
|
|
186
|
+
`RouterBridgeBase.connect()` has an intentional ordering contract used by all framework adapters:
|
|
187
|
+
|
|
188
|
+
- seed `lastSyncedPath` from `actor.currentRoute` in the constructor
|
|
189
|
+
- install the actor route watcher
|
|
190
|
+
- install adapter router subscriptions
|
|
191
|
+
- resolve initial sync using `getInitialRouterPath()` and `actor.initialRoute`
|
|
192
|
+
|
|
193
|
+
That final step distinguishes:
|
|
194
|
+
|
|
195
|
+
- **deep-link:** browser URL differs from the machine's initial route, so router wins and the actor receives `play.route`
|
|
196
|
+
- **restore:** browser URL is still at the machine's initial route while the actor was restored elsewhere, so actor wins and the bridge pushes the actor route back into the router
|
|
197
|
+
|
|
198
|
+
Actor-originated router sync also keeps `isProcessingNavigation` set until the next microtask. That short window intentionally suppresses synchronous router callbacks that echo the same navigation back into the actor.
|
|
199
|
+
|
|
200
|
+
## Diagnostics
|
|
201
|
+
|
|
202
|
+
Router infrastructure reports runtime failures through `PlayDiagnostics` from `@xmachines/play`.
|
|
203
|
+
|
|
204
|
+
- Default behavior uses `consoleDiagnostics`.
|
|
205
|
+
- Messages follow a stable `[scope:code] message` format.
|
|
206
|
+
- Example: `[RouterBridgeBase:PLAY_ROUTER_SYNC_FAILED] Failed to sync actor state from router location.`
|
|
207
|
+
|
|
208
|
+
This keeps router errors observable while letting applications swap in their own diagnostics implementation upstream.
|
|
162
209
|
|
|
163
210
|
## API Reference
|
|
164
211
|
|
|
@@ -176,9 +223,10 @@ const tree = extractMachineRoutes(machine: AnyStateMachine): RouteTree;
|
|
|
176
223
|
|
|
177
224
|
**Returns:** `RouteTree` with:
|
|
178
225
|
|
|
179
|
-
- `
|
|
180
|
-
- `
|
|
181
|
-
- `
|
|
226
|
+
- `byPath: Map<string, RouteNode>` — URL path → route node
|
|
227
|
+
- `byStateId: Map<string, RouteNode>` — State ID → route node
|
|
228
|
+
- `root: RouteNode` — synthetic root node
|
|
229
|
+
- `graph: MachineGraph` — `@statelyai/graph` Graph for hierarchy queries, reachability checks, and transition-aware navigation
|
|
182
230
|
|
|
183
231
|
**Throws:** Error if routes are invalid (malformed paths, missing state IDs, duplicates)
|
|
184
232
|
|
|
@@ -189,58 +237,112 @@ import { extractMachineRoutes } from "@xmachines/play-router";
|
|
|
189
237
|
|
|
190
238
|
const tree = extractMachineRoutes(authMachine);
|
|
191
239
|
|
|
192
|
-
// Query routes
|
|
193
|
-
const loginRoute = tree.
|
|
194
|
-
console.log(loginRoute?.
|
|
240
|
+
// Query routes — use fullPath for URL matching (path is the raw meta.route segment)
|
|
241
|
+
const loginRoute = tree.byStateId.get("login");
|
|
242
|
+
console.log(loginRoute?.fullPath); // "/login"
|
|
243
|
+
console.log(loginRoute?.path); // "/login" (same for absolute routes)
|
|
244
|
+
|
|
245
|
+
// For nested relative routes the distinction matters:
|
|
246
|
+
// meta.route: "overview" under "/dashboard" → path: "overview", fullPath: "/dashboard/overview"
|
|
247
|
+
const overviewRoute = tree.byStateId.get("overview");
|
|
248
|
+
console.log(overviewRoute?.path); // "overview" (raw segment — relative)
|
|
249
|
+
console.log(overviewRoute?.fullPath); // "/dashboard/overview" (resolved — use this)
|
|
195
250
|
|
|
196
251
|
const dashboardRoute = tree.byPath.get("/dashboard");
|
|
197
252
|
console.log(dashboardRoute?.id); // "dashboard"
|
|
198
253
|
```
|
|
199
254
|
|
|
200
|
-
###
|
|
255
|
+
### machineToGraph()
|
|
201
256
|
|
|
202
|
-
|
|
257
|
+
Converts an XState v5 machine to a typed `@statelyai/graph` `Graph`:
|
|
203
258
|
|
|
204
259
|
```typescript
|
|
205
|
-
|
|
206
|
-
|
|
260
|
+
import { machineToGraph } from "@xmachines/play-router";
|
|
261
|
+
import { getChildren, getSuccessors, hasPath } from "@statelyai/graph";
|
|
207
262
|
|
|
208
|
-
|
|
263
|
+
const graph = machineToGraph(machine);
|
|
209
264
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
265
|
+
// Hierarchy queries
|
|
266
|
+
const children = getChildren(graph, "myMachine");
|
|
267
|
+
const successors = getSuccessors(graph, "myMachine.home"); // transition-reachable states
|
|
213
268
|
|
|
214
|
-
|
|
269
|
+
// Reachability
|
|
270
|
+
const reachable = hasPath(graph, "myMachine.home", "myMachine.dashboard");
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
The graph is also available on any `RouteTree` returned by `extractMachineRoutes()`:
|
|
215
274
|
|
|
216
275
|
```typescript
|
|
217
|
-
|
|
276
|
+
const tree = extractMachineRoutes(machine);
|
|
218
277
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
278
|
+
// Use @statelyai/graph algorithms directly on the graph
|
|
279
|
+
const successors = getSuccessors(tree.graph!, "myMachine.home");
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
**Node data** (`MachineNodeData`):
|
|
283
|
+
|
|
284
|
+
- `stateId: string` — XState state ID (e.g., `"myMachine.dashboard.overview"`)
|
|
285
|
+
- `type` — `"atomic" | "compound" | "parallel" | "final" | "history"`
|
|
286
|
+
- `meta?: Record<string, unknown>` — original state meta object
|
|
287
|
+
- `route?: string` — extracted route path from `meta.route`
|
|
288
|
+
|
|
289
|
+
**Edge data** (`MachineEdgeData`):
|
|
290
|
+
|
|
291
|
+
- `eventType: string` — event type triggering this transition
|
|
292
|
+
- `guardType?: string` — guard name/description (if transition is guarded)
|
|
293
|
+
|
|
294
|
+
### createRouteMapFromMachine() / createRouteMapFromTree()
|
|
295
|
+
|
|
296
|
+
Both build a `BaseRouteMap` using `node.fullPath` (absolute resolved paths) for browser URL matching.
|
|
297
|
+
|
|
298
|
+
```typescript
|
|
299
|
+
// Single-call form — preferred for XState machines:
|
|
300
|
+
import { createRouteMapFromMachine } from "@xmachines/play-router";
|
|
301
|
+
const routeMap = createRouteMapFromMachine(machine);
|
|
302
|
+
|
|
303
|
+
// Two-step form — used by framework adapter packages:
|
|
304
|
+
import { extractMachineRoutes, createRouteMapFromTree } from "@xmachines/play-router";
|
|
305
|
+
const routeTree = extractMachineRoutes(machine);
|
|
306
|
+
const routeMap = createRouteMapFromTree(routeTree);
|
|
224
307
|
```
|
|
225
308
|
|
|
309
|
+
Both forms produce identical results. `createRouteMapFromTree` is exposed for framework adapters (React Router, TanStack Router) that need to hold the `RouteTree` separately for other purposes (e.g. graph queries).
|
|
310
|
+
|
|
311
|
+
**`node.path` vs `node.fullPath`:** Every `RouteNode` carries both fields. `path` is the raw `meta.route` segment as declared in the machine — it may be relative (e.g. `"overview"`). `fullPath` is the resolved absolute path (e.g. `"/dashboard/overview"`). Route maps always use `fullPath`. Only use `path` if you specifically need the declared segment.
|
|
312
|
+
|
|
226
313
|
### Query Utilities
|
|
227
314
|
|
|
228
315
|
```typescript
|
|
229
|
-
|
|
230
|
-
|
|
316
|
+
import {
|
|
317
|
+
getNavigableRoutes,
|
|
318
|
+
getRoutableRoutes,
|
|
319
|
+
routeExists,
|
|
320
|
+
getTransitionReachableRoutes,
|
|
321
|
+
isRouteReachable,
|
|
322
|
+
} from "@xmachines/play-router";
|
|
323
|
+
|
|
324
|
+
// Child routes (hierarchical + transition-reachable)
|
|
325
|
+
const navigable = getNavigableRoutes(tree, "dashboard");
|
|
231
326
|
|
|
232
|
-
//
|
|
327
|
+
// All routable routes as flat array
|
|
328
|
+
const allRoutes = getRoutableRoutes(tree);
|
|
329
|
+
|
|
330
|
+
// Check path exists
|
|
233
331
|
const exists = routeExists(tree, "/profile/:userId");
|
|
234
|
-
```
|
|
235
332
|
|
|
236
|
-
|
|
333
|
+
// Transition-aware: which state IDs are reachable via transitions?
|
|
334
|
+
const reachableIds = getTransitionReachableRoutes(tree.graph!, "myMachine.home");
|
|
335
|
+
|
|
336
|
+
// Is there any path from state A to state B via transitions?
|
|
337
|
+
const canReach = isRouteReachable(tree.graph!, "myMachine.home", "myMachine.dashboard");
|
|
338
|
+
```
|
|
237
339
|
|
|
238
340
|
## Examples
|
|
239
341
|
|
|
240
342
|
### Route Detection
|
|
241
343
|
|
|
242
344
|
```typescript
|
|
243
|
-
import { extractMachineRoutes } from "@xmachines/play-router";
|
|
345
|
+
import { extractMachineRoutes, findRouteByPath } from "@xmachines/play-router";
|
|
244
346
|
import { createMachine } from "xstate";
|
|
245
347
|
|
|
246
348
|
const machine = createMachine({
|
|
@@ -263,11 +365,11 @@ const machine = createMachine({
|
|
|
263
365
|
|
|
264
366
|
const tree = extractMachineRoutes(machine);
|
|
265
367
|
|
|
266
|
-
// Bidirectional mapping
|
|
267
|
-
const profileById = tree.
|
|
268
|
-
console.log(profileById?.
|
|
368
|
+
// Bidirectional mapping — use fullPath for URLs; path is the raw meta.route segment
|
|
369
|
+
const profileById = tree.byStateId.get("profile");
|
|
370
|
+
console.log(profileById?.fullPath); // "/profile/:userId"
|
|
269
371
|
|
|
270
|
-
const profileByPath = tree
|
|
372
|
+
const profileByPath = findRouteByPath(tree, "/profile/user123");
|
|
271
373
|
console.log(profileByPath?.id); // "profile"
|
|
272
374
|
```
|
|
273
375
|
|
|
@@ -311,14 +413,14 @@ const dashboardChildren = getNavigableRoutes(tree, "dashboard");
|
|
|
311
413
|
console.log(dashboardChildren.map((r) => r.id)); // ["overview", "analytics"]
|
|
312
414
|
|
|
313
415
|
// Route inheritance
|
|
314
|
-
const analyticsRoute = tree.
|
|
315
|
-
console.log(analyticsRoute?.
|
|
416
|
+
const analyticsRoute = tree.byStateId.get("analytics");
|
|
417
|
+
console.log(analyticsRoute?.fullPath); // "/analytics" (absolute route)
|
|
316
418
|
```
|
|
317
419
|
|
|
318
420
|
### Pattern Matching
|
|
319
421
|
|
|
320
422
|
```typescript
|
|
321
|
-
import { extractMachineRoutes } from "@xmachines/play-router";
|
|
423
|
+
import { extractMachineRoutes, findRouteByPath } from "@xmachines/play-router";
|
|
322
424
|
|
|
323
425
|
const machine = createMachine({
|
|
324
426
|
states: {
|
|
@@ -336,13 +438,13 @@ const machine = createMachine({
|
|
|
336
438
|
const tree = extractMachineRoutes(machine);
|
|
337
439
|
|
|
338
440
|
// Pattern matching for actual URLs
|
|
339
|
-
const userRoute = tree
|
|
441
|
+
const userRoute = findRouteByPath(tree, "/user/user123");
|
|
340
442
|
console.log(userRoute?.id); // "user"
|
|
341
443
|
|
|
342
|
-
const settingsDefault = tree
|
|
444
|
+
const settingsDefault = findRouteByPath(tree, "/settings");
|
|
343
445
|
console.log(settingsDefault?.id); // "settings" (optional param)
|
|
344
446
|
|
|
345
|
-
const settingsProfile = tree
|
|
447
|
+
const settingsProfile = findRouteByPath(tree, "/settings/profile");
|
|
346
448
|
console.log(settingsProfile?.id); // "settings" (with param)
|
|
347
449
|
```
|
|
348
450
|
|
|
@@ -388,7 +490,7 @@ states: {
|
|
|
388
490
|
},
|
|
389
491
|
relative: {
|
|
390
492
|
id: "relative",
|
|
391
|
-
meta: { route: "relative", view: { component: "Relative" } }, // No leading / → inherits parent
|
|
493
|
+
meta: { route: "relative", view: { component: "Relative" } }, // No leading / → inherits parent path prefix
|
|
392
494
|
// Final path: "/parent/relative"
|
|
393
495
|
},
|
|
394
496
|
},
|
|
@@ -409,20 +511,78 @@ meta: {
|
|
|
409
511
|
|
|
410
512
|
**Parameter substitution:** Values extracted from context or event params (handled by play-xstate adapter).
|
|
411
513
|
|
|
514
|
+
## Security Utilities
|
|
515
|
+
|
|
516
|
+
### `sanitizePathname()`
|
|
517
|
+
|
|
518
|
+
Normalize a raw pathname before route-map lookup. Used internally by `RouterBridgeBase.syncActorFromRouter()` and available to adapters that bypass the base class:
|
|
519
|
+
|
|
520
|
+
```typescript
|
|
521
|
+
import { sanitizePathname } from "@xmachines/play-router";
|
|
522
|
+
|
|
523
|
+
// In a custom adapter's route-watch callback:
|
|
524
|
+
const clean = sanitizePathname(rawPath);
|
|
525
|
+
if (clean === null) return; // Reject malformed/oversized paths (> 2048 chars)
|
|
526
|
+
// Proceed with clean normalized path
|
|
527
|
+
```
|
|
528
|
+
|
|
529
|
+
Returns `null` for paths exceeding 2048 characters. Strips query fragments, hash fragments, and collapses duplicate slashes.
|
|
530
|
+
|
|
531
|
+
---
|
|
532
|
+
|
|
533
|
+
## Error Handling
|
|
534
|
+
|
|
535
|
+
All runtime errors thrown by this package extend `PlayError` from `@xmachines/play` and
|
|
536
|
+
are exported from the `./errors` subpath:
|
|
537
|
+
|
|
538
|
+
```typescript
|
|
539
|
+
import {
|
|
540
|
+
RouterSyncError,
|
|
541
|
+
URLPatternUnavailableError,
|
|
542
|
+
InvalidRoutePatternError,
|
|
543
|
+
} from "@xmachines/play-router/errors";
|
|
544
|
+
```
|
|
545
|
+
|
|
546
|
+
| Class | Code | When thrown |
|
|
547
|
+
| ---------------------------- | --------------------------------------- | -------------------------------------------------------------- |
|
|
548
|
+
| `RouterSyncError` | `PLAY_ROUTER_SYNC_FAILED` | `syncActorFromRouter()` fails to send a `play.route` event |
|
|
549
|
+
| `URLPatternUnavailableError` | `PLAY_ROUTE_MAP_URLPATTERN_UNAVAILABLE` | `createRouteMap()` called before URLPattern polyfill is loaded |
|
|
550
|
+
| `InvalidRoutePatternError` | `PLAY_ROUTE_MAP_INVALID_PATTERN` | A route pattern string is rejected by URLPattern constructor |
|
|
551
|
+
|
|
552
|
+
`InvalidRoutePatternError` carries a `pattern: string` field with the offending pattern.
|
|
553
|
+
|
|
554
|
+
```typescript
|
|
555
|
+
import { PlayError } from "@xmachines/play";
|
|
556
|
+
import { RouterSyncError } from "@xmachines/play-router/errors";
|
|
557
|
+
|
|
558
|
+
try {
|
|
559
|
+
bridge.connect();
|
|
560
|
+
} catch (err) {
|
|
561
|
+
if (err instanceof RouterSyncError) {
|
|
562
|
+
// err.cause — the original error that triggered the sync failure
|
|
563
|
+
monitoringService.record(err);
|
|
564
|
+
} else if (err instanceof PlayError) {
|
|
565
|
+
console.error(`[${err.scope}:${err.code}] ${err.message}`);
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
```
|
|
569
|
+
|
|
412
570
|
## Architecture
|
|
413
571
|
|
|
414
572
|
This package enables **Actor Authority (INV-01)**:
|
|
415
573
|
|
|
416
574
|
1. **Routes derive from machine:** Business logic defines routes in state machine, not external config
|
|
417
|
-
2. **
|
|
418
|
-
3. **Bidirectional mapping:** Fast lookup by path (browser URL) or by ID (state machine)
|
|
419
|
-
4. **
|
|
575
|
+
2. **Graph-based extraction:** `machineToGraph()` converts machines to typed `@statelyai/graph` `Graph` objects with hierarchy, transition edges, and route metadata
|
|
576
|
+
3. **Bidirectional mapping:** Fast lookup by path (browser URL) or by state ID (state machine)
|
|
577
|
+
4. **Transition-aware queries:** `getTransitionReachableRoutes()` and `isRouteReachable()` use graph algorithms for navigation reachability
|
|
578
|
+
5. **Build-time validation:** Invalid routes throw errors during extraction, not runtime
|
|
420
579
|
|
|
421
580
|
**Enhancements:**
|
|
422
581
|
|
|
423
|
-
- `meta.route` detection via state metadata
|
|
582
|
+
- `meta.route` detection via state metadata — extracted into typed graph node data
|
|
424
583
|
- Pattern matching for dynamic routes (`:param` and `:param?`)
|
|
425
584
|
- State ID ↔ path bidirectional maps for `play.route` events
|
|
585
|
+
- `@statelyai/graph` integration — hierarchy queries, reachability checks, transition traversal
|
|
426
586
|
|
|
427
587
|
## Related Packages
|
|
428
588
|
|
|
@@ -433,4 +593,7 @@ This package enables **Actor Authority (INV-01)**:
|
|
|
433
593
|
|
|
434
594
|
## License
|
|
435
595
|
|
|
436
|
-
|
|
596
|
+
Copyright (c) 2016 [Mikael Karon](mailto:mikael@karon.se). All rights reserved.
|
|
597
|
+
|
|
598
|
+
This work is licensed under the terms of the MIT license.
|
|
599
|
+
For a copy, see <https://opensource.org/licenses/MIT>.
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BaseRouteMap — Shared bidirectional route mapping base class
|
|
3
|
+
*
|
|
4
|
+
* Provides bucket-based pattern matching shared across all framework adapters.
|
|
5
|
+
* Adapters extend this class rather than duplicating the pattern-match logic.
|
|
6
|
+
*
|
|
7
|
+
* Algorithm: O(1) exact match via Map, then bucket-based O(k) pattern match
|
|
8
|
+
* where k = routes in the first-segment bucket (typically << total routes).
|
|
9
|
+
* Uses URLPattern for parameterized route matching (same engine as createRouteMap).
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* A single state ID ↔ path mapping entry.
|
|
13
|
+
*
|
|
14
|
+
* Both fields are `readonly` — mappings are immutable once passed to `BaseRouteMap`.
|
|
15
|
+
* Adapter packages re-export a structurally compatible `RouteMapping` type under
|
|
16
|
+
* their own name. This type is published from `@xmachines/play-router` as
|
|
17
|
+
* `BaseRouteMapping` to avoid name collisions with those adapter-local types.
|
|
18
|
+
*
|
|
19
|
+
* @example
|
|
20
|
+
* ```typescript
|
|
21
|
+
* const mapping: BaseRouteMapping = { stateId: "home", path: "/" };
|
|
22
|
+
* const paramMapping: BaseRouteMapping = { stateId: "profile", path: "/profile/:userId" };
|
|
23
|
+
* const optionalMapping: BaseRouteMapping = { stateId: "settings", path: "/settings/:section?" };
|
|
24
|
+
* ```
|
|
25
|
+
*/
|
|
26
|
+
export interface RouteMapping {
|
|
27
|
+
/** State machine state ID (e.g., `"home"`, `"#profile"`) */
|
|
28
|
+
readonly stateId: string;
|
|
29
|
+
/** URL path pattern (e.g., `"/"`, `"/profile/:userId"`, `"/settings/:section?"`) */
|
|
30
|
+
readonly path: string;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Shared bidirectional route map base class.
|
|
34
|
+
*
|
|
35
|
+
* All framework adapter `RouteMap` classes extend this — they add no logic of their
|
|
36
|
+
* own and inherit the full public API from here.
|
|
37
|
+
*
|
|
38
|
+
* **Lookup strategy:**
|
|
39
|
+
* - Static paths (no `:param`) → O(1) `Map` lookup
|
|
40
|
+
* - Dynamic paths → O(k) bucket-indexed scan using `URLPattern`, where `k` is the number
|
|
41
|
+
* of routes sharing the same first path segment
|
|
42
|
+
* - Results are cached after the first match in an LRU cache (default 500 entries,
|
|
43
|
+
* configurable via the `cacheSize` constructor option)
|
|
44
|
+
*
|
|
45
|
+
* **Pattern syntax** (`:param` / `:param?`):
|
|
46
|
+
* - `:param` — required segment, matches exactly one non-`/` segment
|
|
47
|
+
* - `:param?` — optional segment, matches zero or one non-`/` segment
|
|
48
|
+
*
|
|
49
|
+
* @example
|
|
50
|
+
* ```typescript
|
|
51
|
+
* import { BaseRouteMap } from "@xmachines/play-router";
|
|
52
|
+
*
|
|
53
|
+
* const map = new BaseRouteMap([
|
|
54
|
+
* { stateId: "home", path: "/" },
|
|
55
|
+
* { stateId: "profile", path: "/profile/:userId" },
|
|
56
|
+
* { stateId: "settings", path: "/settings/:section?" },
|
|
57
|
+
* ]);
|
|
58
|
+
*
|
|
59
|
+
* map.getStateIdByPath("/"); // "home"
|
|
60
|
+
* map.getStateIdByPath("/profile/123"); // "profile"
|
|
61
|
+
* map.getStateIdByPath("/settings"); // "settings"
|
|
62
|
+
* map.getStateIdByPath("/unknown"); // null
|
|
63
|
+
*
|
|
64
|
+
* map.getPathByStateId("profile"); // "/profile/:userId"
|
|
65
|
+
* map.getPathByStateId("missing"); // null
|
|
66
|
+
* ```
|
|
67
|
+
*/
|
|
68
|
+
export declare class BaseRouteMap {
|
|
69
|
+
private stateIdToPath;
|
|
70
|
+
private pathToStateId;
|
|
71
|
+
private patternBuckets;
|
|
72
|
+
private pathMatchCache;
|
|
73
|
+
/**
|
|
74
|
+
* Build a route map from an array of state ID ↔ path mappings.
|
|
75
|
+
*
|
|
76
|
+
* Static paths (no `:param`) are indexed in an O(1) `Map`.
|
|
77
|
+
* Parameterized paths are compiled to `URLPattern` and grouped into first-segment
|
|
78
|
+
* buckets for efficient candidate selection.
|
|
79
|
+
*
|
|
80
|
+
* @param mappings - Array of `{ stateId, path }` entries. Order determines
|
|
81
|
+
* priority when multiple patterns could match the same path.
|
|
82
|
+
* @param options - Optional configuration.
|
|
83
|
+
* `options.cacheSize`: Maximum number of resolved parameterized path lookups
|
|
84
|
+
* to cache. Defaults to `500`. Increase for applications with many unique
|
|
85
|
+
* parameterized URL values (e.g. user profile pages with thousands of distinct IDs).
|
|
86
|
+
* After eviction the path falls back to the O(k) bucket pattern scan — correct
|
|
87
|
+
* but slower. Minimum effective value is `1` (QuickLRU constraint).
|
|
88
|
+
*/
|
|
89
|
+
constructor(mappings: RouteMapping[], { cacheSize }?: {
|
|
90
|
+
cacheSize?: number;
|
|
91
|
+
});
|
|
92
|
+
/**
|
|
93
|
+
* Resolve a URL path to its mapped state ID.
|
|
94
|
+
*
|
|
95
|
+
* Strips query strings and hash fragments before matching. Tries an O(1) exact
|
|
96
|
+
* lookup first, then falls back to bucket-indexed pattern matching. Results are
|
|
97
|
+
* cached after the first pattern match.
|
|
98
|
+
*
|
|
99
|
+
* @param path - URL pathname, optionally including query/hash (e.g., `"/profile/123?ref=nav"`)
|
|
100
|
+
* @returns The mapped state ID, or `null` if no route matches
|
|
101
|
+
*
|
|
102
|
+
* @example
|
|
103
|
+
* ```typescript
|
|
104
|
+
* map.getStateIdByPath("/profile/123"); // "profile"
|
|
105
|
+
* map.getStateIdByPath("/unknown"); // null
|
|
106
|
+
* ```
|
|
107
|
+
*/
|
|
108
|
+
getStateIdByPath(path: string): string | null;
|
|
109
|
+
/**
|
|
110
|
+
* Look up the path pattern registered for a state ID.
|
|
111
|
+
*
|
|
112
|
+
* @param stateId - State machine state ID (e.g., `"profile"`, `"#settings"`)
|
|
113
|
+
* @returns The registered path pattern, or `null` if the state ID is unknown
|
|
114
|
+
*
|
|
115
|
+
* @example
|
|
116
|
+
* ```typescript
|
|
117
|
+
* map.getPathByStateId("profile"); // "/profile/:userId"
|
|
118
|
+
* map.getPathByStateId("missing"); // null
|
|
119
|
+
* ```
|
|
120
|
+
*/
|
|
121
|
+
getPathByStateId(stateId: string): string | null;
|
|
122
|
+
}
|
|
123
|
+
//# sourceMappingURL=base-route-map.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"base-route-map.d.ts","sourceRoot":"","sources":["../src/base-route-map.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAgCH;;;;;;;;;;;;;;GAcG;AACH,MAAM,WAAW,YAAY;IAC5B,4DAA4D;IAC5D,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,oFAAoF;IACpF,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;CACtB;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmCG;AACH,qBAAa,YAAY;IACxB,OAAO,CAAC,aAAa,CAAsB;IAC3C,OAAO,CAAC,aAAa,CAAsB;IAC3C,OAAO,CAAC,cAAc,CAGpB;IACF,OAAO,CAAC,cAAc,CAAkC;IAExD;;;;;;;;;;;;;;;OAeG;gBACS,QAAQ,EAAE,YAAY,EAAE,EAAE,EAAE,SAAe,EAAE,GAAE;QAAE,SAAS,CAAC,EAAE,MAAM,CAAA;KAAO;IAiCtF;;;;;;;;;;;;;;;OAeG;IACH,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI;IAuB7C;;;;;;;;;;;OAWG;IACH,gBAAgB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI;CAGhD"}
|