@xmachines/play-router 1.0.0-beta.46 → 1.0.0-beta.48
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +235 -475
- package/package.json +12 -12
package/README.md
CHANGED
|
@@ -1,35 +1,23 @@
|
|
|
1
|
+
<!-- generated-by: gsd-doc-writer -->
|
|
2
|
+
|
|
1
3
|
# @xmachines/play-router
|
|
2
4
|
|
|
3
|
-
|
|
5
|
+
Route tree extraction from XState v5 state machines. Part of [@xmachines/play](../play/README.md) Universal Player Architecture.
|
|
4
6
|
|
|
5
7
|
Graph-based route extraction and bidirectional lookup enabling Actor Authority over navigation.
|
|
6
8
|
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
|
|
11
|
-
It also exports `RouterBridgeBase`, the shared base class used by framework adapters to implement `RouterBridge` with consistent actor↔router synchronization behavior.
|
|
12
|
-
|
|
13
|
-
`RouterBridgeBase` is the policy point; framework adapters are thin ports that implement only framework-specific navigate/subscribe/unsubscribe behavior.
|
|
14
|
-
|
|
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:
|
|
18
|
-
|
|
19
|
-
- **Actor Authority (INV-01):** Routes derive from machine definitions, not external configuration
|
|
20
|
-
|
|
21
|
-
**Routing:** Supports `meta.route` detection, `play.route` event routing, and pattern matching for dynamic parameters.
|
|
9
|
+
Part of the [xmachines-js monorepo](../../README.md).
|
|
22
10
|
|
|
23
11
|
## Installation
|
|
24
12
|
|
|
25
13
|
```bash
|
|
26
|
-
npm install xstate@^5.
|
|
14
|
+
npm install xstate@^5.30.0
|
|
27
15
|
npm install @xmachines/play-router
|
|
28
16
|
```
|
|
29
17
|
|
|
30
18
|
**Peer dependencies:**
|
|
31
19
|
|
|
32
|
-
- `xstate` ^5.
|
|
20
|
+
- `xstate` ^5.30.0 — XState v5 state machine runtime
|
|
33
21
|
|
|
34
22
|
**URLPattern polyfill (Node.js < 24 / older browsers):**
|
|
35
23
|
|
|
@@ -48,552 +36,324 @@ Install the polyfill:
|
|
|
48
36
|
npm install urlpattern-polyfill
|
|
49
37
|
```
|
|
50
38
|
|
|
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
|
|
39
|
+
`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.
|
|
40
|
+
|
|
41
|
+
## Usage
|
|
52
42
|
|
|
53
|
-
|
|
43
|
+
### Extract routes from a machine
|
|
54
44
|
|
|
55
45
|
```typescript
|
|
56
46
|
import { createMachine } from "xstate";
|
|
57
|
-
import { extractMachineRoutes,
|
|
47
|
+
import { extractMachineRoutes, createRouteMap } from "@xmachines/play-router";
|
|
58
48
|
|
|
59
|
-
// Route pattern (recommended)
|
|
60
49
|
const machine = createMachine({
|
|
61
50
|
id: "app",
|
|
62
51
|
initial: "home",
|
|
63
52
|
states: {
|
|
64
53
|
home: {
|
|
65
54
|
id: "home",
|
|
66
|
-
meta: { route: "/"
|
|
55
|
+
meta: { route: "/" },
|
|
67
56
|
},
|
|
68
57
|
dashboard: {
|
|
69
58
|
id: "dashboard",
|
|
70
|
-
meta: { route: "/dashboard"
|
|
59
|
+
meta: { route: "/dashboard" },
|
|
71
60
|
initial: "overview",
|
|
72
61
|
states: {
|
|
73
62
|
overview: {
|
|
74
63
|
id: "overview",
|
|
75
|
-
meta: { route: "/overview"
|
|
64
|
+
meta: { route: "/overview" },
|
|
76
65
|
},
|
|
77
66
|
settings: {
|
|
78
67
|
id: "settings",
|
|
79
|
-
meta: { route: "/settings/:section?"
|
|
68
|
+
meta: { route: "/settings/:section?" }, // optional parameter
|
|
80
69
|
},
|
|
81
70
|
},
|
|
82
71
|
},
|
|
72
|
+
profile: {
|
|
73
|
+
id: "profile",
|
|
74
|
+
meta: { route: "/profile/:userId" }, // required parameter
|
|
75
|
+
},
|
|
83
76
|
},
|
|
84
77
|
});
|
|
85
78
|
|
|
79
|
+
// Build hierarchical route tree with bidirectional maps
|
|
86
80
|
const tree = extractMachineRoutes(machine);
|
|
87
81
|
|
|
88
|
-
//
|
|
89
|
-
|
|
90
|
-
console.log(tree.byStateId.get("overview")); // RouteNode
|
|
82
|
+
// Path → RouteNode
|
|
83
|
+
const node = tree.byPath.get("/dashboard"); // RouteNode for "dashboard"
|
|
91
84
|
|
|
92
|
-
//
|
|
93
|
-
const
|
|
94
|
-
console.log(
|
|
95
|
-
```
|
|
85
|
+
// State ID → RouteNode
|
|
86
|
+
const overview = tree.byStateId.get("overview");
|
|
87
|
+
console.log(overview?.fullPath); // "/overview"
|
|
96
88
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
The demo demonstrates:
|
|
102
|
-
|
|
103
|
-
- **RouteTree extraction** from XState machine meta.route
|
|
104
|
-
- **History API integration** (pushState, popstate)
|
|
105
|
-
- **Bidirectional synchronization** (actor ↔ URL)
|
|
106
|
-
- **Protected route guards** (authentication redirects)
|
|
107
|
-
- **Dynamic route parameters** (/profile/:userId)
|
|
108
|
-
|
|
109
|
-
**Run the demo:**
|
|
110
|
-
|
|
111
|
-
```bash
|
|
112
|
-
npm run dev -w @xmachines/play-dom-router-demo
|
|
89
|
+
// Build a RouteMap for framework adapters
|
|
90
|
+
const routeMap = createRouteMap(machine);
|
|
91
|
+
routeMap.getStateIdByPath("/profile/123"); // "profile"
|
|
92
|
+
routeMap.getPathByStateId("profile"); // "/profile/:userId"
|
|
113
93
|
```
|
|
114
94
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
1. Login with any username
|
|
118
|
-
2. Navigate between home and profile
|
|
119
|
-
3. Use browser back/forward buttons
|
|
120
|
-
4. Try accessing protected routes directly
|
|
121
|
-
|
|
122
|
-
**Key implementation patterns:**
|
|
95
|
+
### Sending `play.route` events
|
|
123
96
|
|
|
124
97
|
```typescript
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
}
|
|
98
|
+
import type { PlayRouteEvent } from "@xmachines/play-router";
|
|
99
|
+
|
|
100
|
+
// Navigate to a state by ID
|
|
101
|
+
const event: PlayRouteEvent = {
|
|
102
|
+
type: "play.route",
|
|
103
|
+
to: "#dashboard",
|
|
104
|
+
};
|
|
105
|
+
actor.send(event);
|
|
106
|
+
|
|
107
|
+
// Navigate with route parameters
|
|
108
|
+
actor.send({
|
|
109
|
+
type: "play.route",
|
|
110
|
+
to: "#profile",
|
|
111
|
+
params: { userId: "123" },
|
|
139
112
|
});
|
|
140
113
|
|
|
141
|
-
//
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
}
|
|
114
|
+
// Navigate with query parameters
|
|
115
|
+
actor.send({
|
|
116
|
+
type: "play.route",
|
|
117
|
+
to: "#settings",
|
|
118
|
+
params: { section: "billing" },
|
|
119
|
+
query: { tab: "invoices" },
|
|
148
120
|
});
|
|
149
|
-
|
|
150
|
-
// Initial URL handling
|
|
151
|
-
const initialPath = window.location.pathname;
|
|
152
|
-
if (initialPath !== "/") {
|
|
153
|
-
const { to, params } = routeMatcher.match(initialPath);
|
|
154
|
-
if (to) {
|
|
155
|
-
actor.send({ type: "play.route", to, params });
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
```
|
|
159
|
-
|
|
160
|
-
This example shows the core routing concepts without framework dependencies, making it ideal for understanding how @xmachines/play-router integrates with browser History API.
|
|
161
|
-
|
|
162
|
-
## Canonical Watcher Lifecycle
|
|
163
|
-
|
|
164
|
-
Bridge implementations should use one watcher flow:
|
|
165
|
-
|
|
166
|
-
1. `notify`
|
|
167
|
-
2. `queueMicrotask`
|
|
168
|
-
3. `getPending()`
|
|
169
|
-
4. read actor route and sync infrastructure state
|
|
170
|
-
5. re-arm with `watch(...)` or `watch()`
|
|
171
|
-
|
|
172
|
-
Watcher notification is one-shot; re-arm is required.
|
|
173
|
-
|
|
174
|
-
## Bridge Cleanup Contract
|
|
175
|
-
|
|
176
|
-
Bridge teardown must be explicit and deterministic:
|
|
177
|
-
|
|
178
|
-
- `disconnect`/`dispose` must unwatch signal subscriptions and unhook router listeners.
|
|
179
|
-
- Do not rely on GC-only cleanup guidance.
|
|
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 suppresses router echoes via `lastSyncedPath`. `syncRouterFromActor` resolves the concrete path and stores it before calling the adapter's navigation method; when the router callback fires with that same path, `syncActorFromRouter` short-circuits at `sanitized === lastSyncedPath` and sends no event.
|
|
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.
|
|
209
|
-
|
|
210
|
-
## API Reference
|
|
211
|
-
|
|
212
|
-
### extractMachineRoutes()
|
|
213
|
-
|
|
214
|
-
Main entry point — crawls state machine, extracts routes, builds tree:
|
|
215
|
-
|
|
216
|
-
```typescript
|
|
217
|
-
const tree = extractMachineRoutes(machine: AnyStateMachine): RouteTree;
|
|
218
121
|
```
|
|
219
122
|
|
|
220
|
-
|
|
123
|
+
### Implementing a `RouterBridgeBase` adapter
|
|
221
124
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
**Returns:** `RouteTree` with:
|
|
225
|
-
|
|
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
|
|
230
|
-
|
|
231
|
-
**Throws:** Error if routes are invalid (malformed paths, missing state IDs, duplicates)
|
|
232
|
-
|
|
233
|
-
**Example:**
|
|
234
|
-
|
|
235
|
-
```typescript
|
|
236
|
-
import { extractMachineRoutes } from "@xmachines/play-router";
|
|
237
|
-
|
|
238
|
-
const tree = extractMachineRoutes(authMachine);
|
|
239
|
-
|
|
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)
|
|
250
|
-
|
|
251
|
-
const dashboardRoute = tree.byPath.get("/dashboard");
|
|
252
|
-
console.log(dashboardRoute?.id); // "dashboard"
|
|
253
|
-
```
|
|
254
|
-
|
|
255
|
-
### machineToGraph()
|
|
256
|
-
|
|
257
|
-
Converts an XState v5 machine to a typed `@statelyai/graph` `Graph`:
|
|
125
|
+
Extend `RouterBridgeBase` and implement the three abstract methods for your framework:
|
|
258
126
|
|
|
259
127
|
```typescript
|
|
260
|
-
import {
|
|
261
|
-
import {
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
```typescript
|
|
276
|
-
const tree = extractMachineRoutes(machine);
|
|
277
|
-
|
|
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`
|
|
128
|
+
import { RouterBridgeBase } from "@xmachines/play-router";
|
|
129
|
+
import type { AbstractActor, Routable } from "@xmachines/play-actor";
|
|
130
|
+
import type { AnyActorLogic } from "xstate";
|
|
131
|
+
|
|
132
|
+
export class MyRouterBridge extends RouterBridgeBase {
|
|
133
|
+
private unsubscribe: (() => void) | null = null;
|
|
134
|
+
|
|
135
|
+
constructor(
|
|
136
|
+
private readonly myRouter: MyRouter,
|
|
137
|
+
actor: AbstractActor<AnyActorLogic> & Routable,
|
|
138
|
+
routeMap: ReturnType<typeof createRouteMap>,
|
|
139
|
+
) {
|
|
140
|
+
super(actor, routeMap);
|
|
141
|
+
}
|
|
288
142
|
|
|
289
|
-
|
|
143
|
+
// Tell the framework router to navigate to a path
|
|
144
|
+
protected navigateRouter(path: string): void {
|
|
145
|
+
this.myRouter.navigate(path);
|
|
146
|
+
}
|
|
290
147
|
|
|
291
|
-
|
|
292
|
-
|
|
148
|
+
// Subscribe to router location changes, call syncActorFromRouter on each
|
|
149
|
+
protected watchRouterChanges(): void {
|
|
150
|
+
this.unsubscribe = this.myRouter.subscribe((location) => {
|
|
151
|
+
this.syncActorFromRouter(location.pathname, location.search);
|
|
152
|
+
});
|
|
153
|
+
}
|
|
293
154
|
|
|
294
|
-
|
|
155
|
+
// Unsubscribe from router location changes
|
|
156
|
+
protected unwatchRouterChanges(): void {
|
|
157
|
+
this.unsubscribe?.();
|
|
158
|
+
this.unsubscribe = null;
|
|
159
|
+
}
|
|
295
160
|
|
|
296
|
-
|
|
161
|
+
// Provide the router's current path for initial deep-link sync
|
|
162
|
+
protected override getInitialRouterPath(): string {
|
|
163
|
+
return this.myRouter.state.location.pathname;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
297
166
|
|
|
298
|
-
|
|
299
|
-
// Single-call form — preferred for XState machines:
|
|
300
|
-
import { createRouteMap } from "@xmachines/play-router";
|
|
167
|
+
// Usage
|
|
301
168
|
const routeMap = createRouteMap(machine);
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
const routeMap = createRouteMapFromTree(routeTree);
|
|
169
|
+
const bridge = new MyRouterBridge(myRouter, actor, routeMap);
|
|
170
|
+
bridge.connect();
|
|
171
|
+
// ...
|
|
172
|
+
bridge.disconnect();
|
|
307
173
|
```
|
|
308
174
|
|
|
309
|
-
|
|
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
|
-
|
|
313
|
-
### Query Utilities
|
|
175
|
+
## API Summary
|
|
314
176
|
|
|
315
|
-
|
|
316
|
-
import {
|
|
317
|
-
getNavigableRoutes,
|
|
318
|
-
getRoutableRoutes,
|
|
319
|
-
routeExists,
|
|
320
|
-
getTransitionReachableRoutes,
|
|
321
|
-
isRouteReachable,
|
|
322
|
-
} from "@xmachines/play-router";
|
|
177
|
+
### Route Extraction
|
|
323
178
|
|
|
324
|
-
|
|
325
|
-
|
|
179
|
+
| Export | Description |
|
|
180
|
+
| ---------------------------------------- | ---------------------------------------------------------------------------------- |
|
|
181
|
+
| `extractMachineRoutes(machine)` | Convert an XState machine to a `RouteTree` with bidirectional state ID ↔ path maps |
|
|
182
|
+
| `createRouteMap(machine, options?)` | Build a `RouteMap` directly from a machine (preferred form for adapters) |
|
|
183
|
+
| `createRouteMapFromTree(tree, options?)` | Build a `RouteMap` from an already-extracted `RouteTree` |
|
|
184
|
+
| `buildRouteTree(routes)` | Build a `RouteTree` from an array of `RouteInfo` objects |
|
|
185
|
+
| `machineToGraph(machine)` | Convert a machine to a typed `@statelyai/graph` `Graph` for graph algorithm access |
|
|
326
186
|
|
|
327
|
-
|
|
328
|
-
const allRoutes = getRoutableRoutes(tree);
|
|
187
|
+
### Route Matching
|
|
329
188
|
|
|
330
|
-
|
|
331
|
-
|
|
189
|
+
| Export | Description |
|
|
190
|
+
| ----------------------------- | ------------------------------------------------------------------------------------------ |
|
|
191
|
+
| `createRouteMatcher(tree)` | Create a `RouteMatcher` that matches URL paths to `#stateId` values and extracts params |
|
|
192
|
+
| `RouteMap` | Bidirectional `stateId ↔ path` lookup class; supports O(1) exact and O(k) pattern matching |
|
|
193
|
+
| `findRouteById(tree, id)` | Look up a `RouteNode` by state ID |
|
|
194
|
+
| `findRouteByPath(tree, path)` | Look up a `RouteNode` by URL path (supports dynamic patterns) |
|
|
332
195
|
|
|
333
|
-
|
|
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
|
-
```
|
|
339
|
-
|
|
340
|
-
## Examples
|
|
341
|
-
|
|
342
|
-
### Route Detection
|
|
343
|
-
|
|
344
|
-
```typescript
|
|
345
|
-
import { extractMachineRoutes, findRouteByPath } from "@xmachines/play-router";
|
|
346
|
-
import { createMachine } from "xstate";
|
|
347
|
-
|
|
348
|
-
const machine = createMachine({
|
|
349
|
-
initial: "home",
|
|
350
|
-
states: {
|
|
351
|
-
home: {
|
|
352
|
-
id: "home",
|
|
353
|
-
meta: { route: "/", view: { component: "Home" } },
|
|
354
|
-
},
|
|
355
|
-
profile: {
|
|
356
|
-
id: "profile",
|
|
357
|
-
meta: { route: "/profile/:userId", view: { component: "Profile" } }, // Parameter pattern
|
|
358
|
-
},
|
|
359
|
-
settings: {
|
|
360
|
-
id: "settings",
|
|
361
|
-
meta: { route: "/settings/:section?", view: { component: "Settings" } }, // Optional parameter
|
|
362
|
-
},
|
|
363
|
-
},
|
|
364
|
-
});
|
|
365
|
-
|
|
366
|
-
const tree = extractMachineRoutes(machine);
|
|
367
|
-
|
|
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"
|
|
371
|
-
|
|
372
|
-
const profileByPath = findRouteByPath(tree, "/profile/user123");
|
|
373
|
-
console.log(profileByPath?.id); // "profile"
|
|
374
|
-
```
|
|
375
|
-
|
|
376
|
-
### Hierarchical Route Tree
|
|
377
|
-
|
|
378
|
-
```typescript
|
|
379
|
-
import { extractMachineRoutes, getNavigableRoutes } from "@xmachines/play-router";
|
|
380
|
-
|
|
381
|
-
const machine = createMachine({
|
|
382
|
-
initial: "app",
|
|
383
|
-
states: {
|
|
384
|
-
app: {
|
|
385
|
-
id: "app",
|
|
386
|
-
meta: { route: "/", view: { component: "AppShell" } },
|
|
387
|
-
initial: "dashboard",
|
|
388
|
-
states: {
|
|
389
|
-
dashboard: {
|
|
390
|
-
id: "dashboard",
|
|
391
|
-
meta: { route: "/dashboard", view: { component: "Dashboard" } },
|
|
392
|
-
initial: "overview",
|
|
393
|
-
states: {
|
|
394
|
-
overview: {
|
|
395
|
-
id: "overview",
|
|
396
|
-
meta: { route: "/overview", view: { component: "Overview" } },
|
|
397
|
-
},
|
|
398
|
-
analytics: {
|
|
399
|
-
id: "analytics",
|
|
400
|
-
meta: { route: "/analytics", view: { component: "Analytics" } },
|
|
401
|
-
},
|
|
402
|
-
},
|
|
403
|
-
},
|
|
404
|
-
},
|
|
405
|
-
},
|
|
406
|
-
},
|
|
407
|
-
});
|
|
408
|
-
|
|
409
|
-
const tree = extractMachineRoutes(machine);
|
|
410
|
-
|
|
411
|
-
// Get child routes
|
|
412
|
-
const dashboardChildren = getNavigableRoutes(tree, "dashboard");
|
|
413
|
-
console.log(dashboardChildren.map((r) => r.id)); // ["overview", "analytics"]
|
|
414
|
-
|
|
415
|
-
// Route inheritance
|
|
416
|
-
const analyticsRoute = tree.byStateId.get("analytics");
|
|
417
|
-
console.log(analyticsRoute?.fullPath); // "/analytics" (absolute route)
|
|
418
|
-
```
|
|
196
|
+
### Query Utilities
|
|
419
197
|
|
|
420
|
-
|
|
198
|
+
| Export | Description |
|
|
199
|
+
| ------------------------------------------------- | ------------------------------------------------------------------------- |
|
|
200
|
+
| `getRoutableRoutes(tree)` | All routable `RouteNode`s as a flat array |
|
|
201
|
+
| `getNavigableRoutes(tree, stateId)` | Child routes reachable from a state (hierarchical + transition-reachable) |
|
|
202
|
+
| `routeExists(tree, path)` | Check whether a path is registered in the tree |
|
|
203
|
+
| `getTransitionReachableRoutes(graph, stateId)` | Route paths reachable via XState transitions from a state |
|
|
204
|
+
| `isRouteReachable(graph, fromStateId, toStateId)` | Check whether a transition path exists between two states |
|
|
205
|
+
|
|
206
|
+
### Router Bridge
|
|
207
|
+
|
|
208
|
+
| Export | Description |
|
|
209
|
+
| --------------------------------------- | ------------------------------------------------------------------------------------- |
|
|
210
|
+
| `RouterBridgeBase` | Abstract base class for framework router adapters; implements `RouterBridge` protocol |
|
|
211
|
+
| `sanitizePathname(path)` | Normalize a raw pathname; returns `null` for paths > 2048 chars or malformed input |
|
|
212
|
+
| `buildPlayRouteEvent(options)` | Build a `PlayRouteEvent` from a pathname + route-map match result |
|
|
213
|
+
| `extractRouteParams(pathname, pattern)` | Extract path parameters from a URL using URLPattern |
|
|
214
|
+
| `extractQuery(search)` | Extract query parameters from a URL search string |
|
|
215
|
+
|
|
216
|
+
### Validation
|
|
217
|
+
|
|
218
|
+
| Export | Description |
|
|
219
|
+
| ---------------------------------------- | ------------------------------------------------- |
|
|
220
|
+
| `validateRouteFormat(route, stateId)` | Assert route path is non-empty |
|
|
221
|
+
| `validateStateExists(stateId, stateIds)` | Assert a state ID is present in the machine graph |
|
|
222
|
+
| `detectDuplicateRoutes(routes)` | Throw if any two states share the same URL path |
|
|
223
|
+
|
|
224
|
+
### Key Types
|
|
225
|
+
|
|
226
|
+
| Export | Description |
|
|
227
|
+
| ---------------------------------- | -------------------------------------------------------------------------------------- |
|
|
228
|
+
| `RouterBridge` | Interface for `connect()` / `disconnect()` lifecycle |
|
|
229
|
+
| `RouteTree` | Hierarchical tree with `root`, `byStateId`, `byPath`, and optional `graph` |
|
|
230
|
+
| `RouteNode` | Single node in the tree with `id`, `path`, `fullPath`, `stateId`, `children`, `parent` |
|
|
231
|
+
| `RouteInfo` | Flat route descriptor extracted from a state node |
|
|
232
|
+
| `PlayRouteEvent` | Routing event `{ type: "play.route", to, params?, query? }` |
|
|
233
|
+
| `RouteMapping` | `{ stateId, path }` pair used to build a `RouteMap` |
|
|
234
|
+
| `RouteMapping as BaseRouteMapping` | Alias for `RouteMapping` (backwards-compat re-export) |
|
|
235
|
+
| `MachineGraph` | Typed `@statelyai/graph` Graph with `MachineNodeData` / `MachineEdgeData` |
|
|
236
|
+
| `WindowLike` | Injectable minimal `window` interface for SSR / testing |
|
|
237
|
+
| `LocationLike` | Injectable minimal `location` interface for SSR / testing |
|
|
238
|
+
|
|
239
|
+
### Errors (subpath `@xmachines/play-router/errors`)
|
|
240
|
+
|
|
241
|
+
| Class | Code | When thrown |
|
|
242
|
+
| ---------------------------- | --------------------------------------- | ----------------------------------------------------------------- |
|
|
243
|
+
| `RouterSyncError` | `PLAY_ROUTER_SYNC_FAILED` | `syncActorFromRouter()` fails to send a `play.route` event |
|
|
244
|
+
| `DuplicateBridgeError` | `PLAY_ROUTER_DUPLICATE_BRIDGE` | A second bridge tries to connect to an actor that already has one |
|
|
245
|
+
| `URLPatternUnavailableError` | `PLAY_ROUTE_MAP_URLPATTERN_UNAVAILABLE` | URLPattern API is absent and no polyfill is loaded |
|
|
246
|
+
| `InvalidRoutePatternError` | `PLAY_ROUTE_MAP_INVALID_PATTERN` | A route pattern string is rejected by the URLPattern constructor |
|
|
247
|
+
| `EmptyRoutePathError` | `PLAY_ROUTE_EMPTY_PATH` | A state declares `meta.route: ""` |
|
|
248
|
+
| `InvalidStateIdError` | `PLAY_ROUTE_INVALID_STATE_ID` | A route references a state ID not in the machine graph |
|
|
249
|
+
| `DuplicateRoutePathError` | `PLAY_ROUTE_DUPLICATE_PATH` | Two or more states share the same URL path |
|
|
250
|
+
| `UnknownStateTypeError` | `PLAY_ROUTE_UNKNOWN_STATE_TYPE` | A state node has an unrecognised XState `.type` value |
|
|
421
251
|
|
|
422
252
|
```typescript
|
|
423
|
-
import {
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
id: "user",
|
|
429
|
-
meta: { route: "/user/:userId", view: { component: "User" } }, // Required parameter
|
|
430
|
-
},
|
|
431
|
-
settings: {
|
|
432
|
-
id: "settings",
|
|
433
|
-
meta: { route: "/settings/:section?", view: { component: "Settings" } }, // Optional parameter
|
|
434
|
-
},
|
|
435
|
-
},
|
|
436
|
-
});
|
|
437
|
-
|
|
438
|
-
const tree = extractMachineRoutes(machine);
|
|
439
|
-
|
|
440
|
-
// Pattern matching for actual URLs
|
|
441
|
-
const userRoute = findRouteByPath(tree, "/user/user123");
|
|
442
|
-
console.log(userRoute?.id); // "user"
|
|
443
|
-
|
|
444
|
-
const settingsDefault = findRouteByPath(tree, "/settings");
|
|
445
|
-
console.log(settingsDefault?.id); // "settings" (optional param)
|
|
253
|
+
import {
|
|
254
|
+
RouterSyncError,
|
|
255
|
+
DuplicateBridgeError,
|
|
256
|
+
URLPatternUnavailableError,
|
|
257
|
+
} from "@xmachines/play-router/errors";
|
|
446
258
|
|
|
447
|
-
|
|
448
|
-
|
|
259
|
+
try {
|
|
260
|
+
bridge.connect();
|
|
261
|
+
} catch (err) {
|
|
262
|
+
if (err instanceof DuplicateBridgeError) {
|
|
263
|
+
// Actor already bridged — call disconnect() first
|
|
264
|
+
} else if (err instanceof RouterSyncError) {
|
|
265
|
+
console.error("Router sync failed:", err.message, err.cause);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
449
268
|
```
|
|
450
269
|
|
|
451
270
|
## Route Configuration
|
|
452
271
|
|
|
453
|
-
###
|
|
272
|
+
### `meta.route` patterns
|
|
454
273
|
|
|
455
|
-
|
|
456
|
-
states: {
|
|
457
|
-
dashboard: {
|
|
458
|
-
id: "dashboard", // Required for bidirectional lookup
|
|
459
|
-
meta: {
|
|
460
|
-
route: "/dashboard", // URL path - marks state as routable
|
|
461
|
-
},
|
|
462
|
-
},
|
|
463
|
-
}
|
|
464
|
-
```
|
|
465
|
-
|
|
466
|
-
### Alternative Pattern
|
|
274
|
+
Routes are declared on XState state nodes via the `meta.route` field:
|
|
467
275
|
|
|
468
276
|
```typescript
|
|
469
277
|
states: {
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
278
|
+
home: {
|
|
279
|
+
id: "home",
|
|
280
|
+
meta: { route: "/" }, // static route
|
|
281
|
+
},
|
|
282
|
+
profile: {
|
|
283
|
+
id: "profile",
|
|
284
|
+
meta: { route: "/profile/:userId" }, // required parameter
|
|
285
|
+
},
|
|
286
|
+
settings: {
|
|
287
|
+
id: "settings",
|
|
288
|
+
meta: { route: "/settings/:section?" }, // optional parameter
|
|
289
|
+
},
|
|
290
|
+
docs: {
|
|
291
|
+
id: "docs",
|
|
292
|
+
meta: { route: { path: "/docs", title: "Documentation" } }, // object form
|
|
293
|
+
},
|
|
476
294
|
}
|
|
477
295
|
```
|
|
478
296
|
|
|
479
|
-
###
|
|
297
|
+
### Relative vs absolute paths
|
|
480
298
|
|
|
481
|
-
|
|
482
|
-
states: {
|
|
483
|
-
parent: {
|
|
484
|
-
id: "parent",
|
|
485
|
-
meta: { route: "/parent", view: { component: "Parent" } },
|
|
486
|
-
states: {
|
|
487
|
-
absolute: {
|
|
488
|
-
id: "absolute",
|
|
489
|
-
meta: { route: "/absolute", view: { component: "Absolute" } }, // Starts with / → doesn't inherit
|
|
490
|
-
},
|
|
491
|
-
relative: {
|
|
492
|
-
id: "relative",
|
|
493
|
-
meta: { route: "relative", view: { component: "Relative" } }, // No leading / → inherits parent path prefix
|
|
494
|
-
// Final path: "/parent/relative"
|
|
495
|
-
},
|
|
496
|
-
},
|
|
497
|
-
},
|
|
498
|
-
}
|
|
499
|
-
```
|
|
500
|
-
|
|
501
|
-
### Dynamic Parameters
|
|
299
|
+
Child routes with a leading `/` are absolute (do not inherit the parent path). Without a leading `/`, they resolve relative to their nearest routable ancestor:
|
|
502
300
|
|
|
503
301
|
```typescript
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
302
|
+
states: {
|
|
303
|
+
dashboard: {
|
|
304
|
+
id: "dashboard",
|
|
305
|
+
meta: { route: "/dashboard" },
|
|
306
|
+
states: {
|
|
307
|
+
overview: {
|
|
308
|
+
id: "overview",
|
|
309
|
+
meta: { route: "/overview" }, // absolute → fullPath: "/overview"
|
|
310
|
+
},
|
|
311
|
+
stats: {
|
|
312
|
+
id: "stats",
|
|
313
|
+
meta: { route: "stats" }, // relative → fullPath: "/dashboard/stats"
|
|
314
|
+
},
|
|
315
|
+
},
|
|
316
|
+
},
|
|
509
317
|
}
|
|
510
318
|
```
|
|
511
319
|
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
## Security Utilities
|
|
515
|
-
|
|
516
|
-
### `sanitizePathname()`
|
|
320
|
+
Always use `node.fullPath` (never `node.path`) for browser URL matching and route map construction.
|
|
517
321
|
|
|
518
|
-
|
|
322
|
+
## Testing
|
|
519
323
|
|
|
520
|
-
```
|
|
521
|
-
|
|
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:
|
|
324
|
+
```bash
|
|
325
|
+
# Run tests for this package
|
|
326
|
+
npm test -w @xmachines/play-router
|
|
537
327
|
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
RouterSyncError,
|
|
541
|
-
URLPatternUnavailableError,
|
|
542
|
-
InvalidRoutePatternError,
|
|
543
|
-
} from "@xmachines/play-router/errors";
|
|
328
|
+
# Watch mode
|
|
329
|
+
npm run test:watch -w @xmachines/play-router
|
|
544
330
|
```
|
|
545
331
|
|
|
546
|
-
|
|
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.
|
|
332
|
+
The package exports a router bridge contract test suite for adapter authors:
|
|
553
333
|
|
|
554
334
|
```typescript
|
|
555
|
-
import {
|
|
556
|
-
import { RouterSyncError } from "@xmachines/play-router/errors";
|
|
335
|
+
import { runBridgeContractTests } from "@xmachines/play-router/test/contract.js";
|
|
557
336
|
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
} else if (err instanceof PlayError) {
|
|
565
|
-
console.error(`[${err.scope}:${err.code}] ${err.message}`);
|
|
566
|
-
}
|
|
567
|
-
}
|
|
337
|
+
runBridgeContractTests({
|
|
338
|
+
name: "MyRouterBridge",
|
|
339
|
+
createHarness(initialPath) {
|
|
340
|
+
// return ContractHarness with bridge, actor, simulateNavigation, getLastNavigatedPath
|
|
341
|
+
},
|
|
342
|
+
});
|
|
568
343
|
```
|
|
569
344
|
|
|
570
|
-
## Architecture
|
|
571
|
-
|
|
572
|
-
This package enables **Actor Authority (INV-01)**:
|
|
573
|
-
|
|
574
|
-
1. **Routes derive from machine:** Business logic defines routes in state machine, not external config
|
|
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
|
|
579
|
-
|
|
580
|
-
**Enhancements:**
|
|
581
|
-
|
|
582
|
-
- `meta.route` detection via state metadata — extracted into typed graph node data
|
|
583
|
-
- Pattern matching for dynamic routes (`:param` and `:param?`)
|
|
584
|
-
- State ID ↔ path bidirectional maps for `play.route` events
|
|
585
|
-
- `@statelyai/graph` integration — hierarchy queries, reachability checks, transition traversal
|
|
586
|
-
|
|
587
345
|
## Related Packages
|
|
588
346
|
|
|
589
|
-
- **[@xmachines/play
|
|
590
|
-
- **[@xmachines/play-
|
|
591
|
-
- **[@xmachines/play-
|
|
592
|
-
- **[@xmachines/play](../play/README.md)**
|
|
347
|
+
- **[@xmachines/play](../play/README.md)** — Core protocol types (`PlayEvent`, `PlayError`)
|
|
348
|
+
- **[@xmachines/play-actor](../play-actor/README.md)** — Abstract actor base class (`AbstractActor`, `Routable`)
|
|
349
|
+
- **[@xmachines/play-signals](../play-signals/README.md)** — TC39 Signals polyfill used for actor route observation
|
|
350
|
+
- **[@xmachines/play-xstate](../play-xstate/README.md)** — XState v5 logic adapter that integrates with route trees
|
|
351
|
+
- **[@xmachines/play-tanstack-react-router](../play-tanstack-react-router/README.md)** — TanStack Router adapter (React)
|
|
352
|
+
- **[@xmachines/play-tanstack-solid-router](../play-tanstack-solid-router/README.md)** — TanStack Router adapter (SolidJS)
|
|
353
|
+
- **[@xmachines/play-react-router](../play-react-router/README.md)** — React Router v7 adapter
|
|
354
|
+
- **[@xmachines/play-vue-router](../play-vue-router/README.md)** — Vue Router adapter
|
|
355
|
+
- **[@xmachines/play-solid-router](../play-solid-router/README.md)** — SolidJS Router adapter
|
|
593
356
|
|
|
594
357
|
## License
|
|
595
358
|
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
This work is licensed under the terms of the MIT license.
|
|
599
|
-
For a copy, see <https://opensource.org/licenses/MIT>.
|
|
359
|
+
MIT — see [LICENSE](LICENSE).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xmachines/play-router",
|
|
3
|
-
"version": "1.0.0-beta.
|
|
3
|
+
"version": "1.0.0-beta.48",
|
|
4
4
|
"description": "Route tree extraction from XState v5 state machines. Part of @xmachines/play Universal Player Architecture.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"routing",
|
|
@@ -46,26 +46,26 @@
|
|
|
46
46
|
"prepublishOnly": "npm run build"
|
|
47
47
|
},
|
|
48
48
|
"dependencies": {
|
|
49
|
-
"@statelyai/graph": "^0.
|
|
50
|
-
"@xmachines/play": "1.0.0-beta.
|
|
51
|
-
"@xmachines/play-actor": "1.0.0-beta.
|
|
52
|
-
"@xmachines/play-signals": "1.0.0-beta.
|
|
49
|
+
"@statelyai/graph": "^0.12.0",
|
|
50
|
+
"@xmachines/play": "1.0.0-beta.48",
|
|
51
|
+
"@xmachines/play-actor": "1.0.0-beta.48",
|
|
52
|
+
"@xmachines/play-signals": "1.0.0-beta.48",
|
|
53
53
|
"quick-lru": "^7.3.0"
|
|
54
54
|
},
|
|
55
55
|
"devDependencies": {
|
|
56
56
|
"@types/node": "^25.6.0",
|
|
57
|
-
"@xmachines/play-xstate": "1.0.0-beta.
|
|
58
|
-
"@xmachines/shared": "1.0.0-beta.
|
|
59
|
-
"oxfmt": "^0.
|
|
60
|
-
"oxlint": "^1.
|
|
57
|
+
"@xmachines/play-xstate": "1.0.0-beta.48",
|
|
58
|
+
"@xmachines/shared": "1.0.0-beta.48",
|
|
59
|
+
"oxfmt": "^0.47.0",
|
|
60
|
+
"oxlint": "^1.62.0",
|
|
61
61
|
"typescript": "^5.9.3 || ^6.0.3",
|
|
62
62
|
"urlpattern-polyfill": "^10.1.0",
|
|
63
|
-
"vitest": "^4.1.
|
|
64
|
-
"xstate": "^5.
|
|
63
|
+
"vitest": "^4.1.5",
|
|
64
|
+
"xstate": "^5.31.0"
|
|
65
65
|
},
|
|
66
66
|
"peerDependencies": {
|
|
67
67
|
"urlpattern-polyfill": "^10.1.0",
|
|
68
|
-
"xstate": "^5.
|
|
68
|
+
"xstate": "^5.31.0"
|
|
69
69
|
},
|
|
70
70
|
"peerDependenciesMeta": {
|
|
71
71
|
"urlpattern-polyfill": {
|