@xmachines/play-router 1.0.0-beta.9 → 1.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 (81) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +246 -341
  3. package/dist/base-route-map.d.ts +38 -17
  4. package/dist/base-route-map.d.ts.map +1 -1
  5. package/dist/base-route-map.js +69 -62
  6. package/dist/base-route-map.js.map +1 -1
  7. package/dist/build-tree.d.ts +7 -0
  8. package/dist/build-tree.d.ts.map +1 -1
  9. package/dist/build-tree.js +21 -6
  10. package/dist/build-tree.js.map +1 -1
  11. package/dist/create-route-map-from-tree.d.ts +33 -0
  12. package/dist/create-route-map-from-tree.d.ts.map +1 -0
  13. package/dist/create-route-map-from-tree.js +43 -0
  14. package/dist/create-route-map-from-tree.js.map +1 -0
  15. package/dist/create-route-map.d.ts +26 -50
  16. package/dist/create-route-map.d.ts.map +1 -1
  17. package/dist/create-route-map.js +24 -114
  18. package/dist/create-route-map.js.map +1 -1
  19. package/dist/errors.d.ts +152 -0
  20. package/dist/errors.d.ts.map +1 -0
  21. package/dist/errors.js +186 -0
  22. package/dist/errors.js.map +1 -0
  23. package/dist/extract-routes.d.ts +5 -31
  24. package/dist/extract-routes.d.ts.map +1 -1
  25. package/dist/extract-routes.js +73 -51
  26. package/dist/extract-routes.js.map +1 -1
  27. package/dist/find-route.d.ts +44 -0
  28. package/dist/find-route.d.ts.map +1 -0
  29. package/dist/find-route.js +103 -0
  30. package/dist/find-route.js.map +1 -0
  31. package/dist/index.d.ts +13 -52
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/index.js +13 -137
  34. package/dist/index.js.map +1 -1
  35. package/dist/machine-to-graph.d.ts +17 -0
  36. package/dist/machine-to-graph.d.ts.map +1 -0
  37. package/dist/machine-to-graph.js +115 -0
  38. package/dist/machine-to-graph.js.map +1 -0
  39. package/dist/query.d.ts +49 -1
  40. package/dist/query.d.ts.map +1 -1
  41. package/dist/query.js +98 -3
  42. package/dist/query.js.map +1 -1
  43. package/dist/router-bridge-base.d.ts +187 -48
  44. package/dist/router-bridge-base.d.ts.map +1 -1
  45. package/dist/router-bridge-base.js +312 -120
  46. package/dist/router-bridge-base.js.map +1 -1
  47. package/dist/router-sync.d.ts +95 -0
  48. package/dist/router-sync.d.ts.map +1 -0
  49. package/dist/router-sync.js +161 -0
  50. package/dist/router-sync.js.map +1 -0
  51. package/dist/types.d.ts +163 -22
  52. package/dist/types.d.ts.map +1 -1
  53. package/dist/url-pattern-utils.d.ts +117 -0
  54. package/dist/url-pattern-utils.d.ts.map +1 -0
  55. package/dist/url-pattern-utils.js +176 -0
  56. package/dist/url-pattern-utils.js.map +1 -0
  57. package/dist/validate-routes.d.ts +32 -14
  58. package/dist/validate-routes.d.ts.map +1 -1
  59. package/dist/validate-routes.js +28 -21
  60. package/dist/validate-routes.js.map +1 -1
  61. package/package.json +34 -14
  62. package/dist/connect-router.d.ts +0 -56
  63. package/dist/connect-router.d.ts.map +0 -1
  64. package/dist/connect-router.js +0 -112
  65. package/dist/connect-router.js.map +0 -1
  66. package/dist/crawl-machine.d.ts +0 -74
  67. package/dist/crawl-machine.d.ts.map +0 -1
  68. package/dist/crawl-machine.js +0 -95
  69. package/dist/crawl-machine.js.map +0 -1
  70. package/dist/create-browser-history.d.ts +0 -93
  71. package/dist/create-browser-history.d.ts.map +0 -1
  72. package/dist/create-browser-history.js +0 -94
  73. package/dist/create-browser-history.js.map +0 -1
  74. package/dist/create-router.d.ts +0 -73
  75. package/dist/create-router.d.ts.map +0 -1
  76. package/dist/create-router.js +0 -63
  77. package/dist/create-router.js.map +0 -1
  78. package/dist/extract-route.d.ts +0 -25
  79. package/dist/extract-route.d.ts.map +0 -1
  80. package/dist/extract-route.js +0 -63
  81. package/dist/extract-route.js.map +0 -1
package/README.md CHANGED
@@ -1,33 +1,23 @@
1
- # @xmachines/play-router
2
-
3
- **Route tree extraction from XState v5 state machines with routing patterns**
4
-
5
- BFS graph crawling and bidirectional route lookup enabling Actor Authority over navigation.
6
-
7
- ## Overview
8
-
9
- `@xmachines/play-router` extracts route trees from XState state machines by crawling the state graph using breadth-first traversal. It extracts `meta.route` paths from state machines and builds hierarchical route trees with bidirectional state ID ↔ path mapping.
1
+ <!-- generated-by: gsd-doc-writer -->
10
2
 
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.
3
+ # @xmachines/play-router
14
4
 
15
- Per [RFC Play v1](https://gitlab.com/xmachin-es/rfc/-/blob/main/src/play-v1.md), this package implements:
5
+ Route tree extraction from XState v5 state machines. Part of [@xmachines/play](../play/README.md) Universal Player Architecture.
16
6
 
17
- - **Actor Authority (INV-01):** Routes derive from machine definitions, not external configuration
7
+ Graph-based route extraction and bidirectional lookup enabling Actor Authority over navigation.
18
8
 
19
- **Routing:** Supports `meta.route` detection, `play.route` event routing, and pattern matching for dynamic parameters.
9
+ Part of the [xmachines-js monorepo](../../README.md).
20
10
 
21
11
  ## Installation
22
12
 
23
13
  ```bash
24
- npm install xstate@^5.0.0
25
- npm install @xmachines/play-router
14
+ pnpm add xstate@^5.31.0
15
+ pnpm add @xmachines/play-router
26
16
  ```
27
17
 
28
18
  **Peer dependencies:**
29
19
 
30
- - `xstate` ^5.0.0 — State machine runtime
20
+ - `xstate` ^5.31.0 — XState v5 state machine runtime
31
21
 
32
22
  **URLPattern polyfill (Node.js < 24 / older browsers):**
33
23
 
@@ -43,416 +33,331 @@ import "urlpattern-polyfill";
43
33
  Install the polyfill:
44
34
 
45
35
  ```bash
46
- npm install urlpattern-polyfill
36
+ pnpm add urlpattern-polyfill
47
37
  ```
48
38
 
49
- `urlpattern-polyfill` is declared as an optional peer dependency. Package managers will not install it automatically — it is the consumer's responsibility to load it when needed.
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.
50
40
 
51
- ## Quick Start
41
+ ## Usage
42
+
43
+ ### Extract routes from a machine
52
44
 
53
45
  ```typescript
54
46
  import { createMachine } from "xstate";
55
- import { extractMachineRoutes } from "@xmachines/play-router";
47
+ import { extractMachineRoutes, createRouteMap } from "@xmachines/play-router";
56
48
 
57
- // Route pattern (recommended)
58
49
  const machine = createMachine({
59
50
  id: "app",
60
51
  initial: "home",
61
52
  states: {
62
53
  home: {
63
54
  id: "home",
64
- meta: { route: "/", view: { component: "Home" } },
55
+ meta: { route: "/" },
65
56
  },
66
57
  dashboard: {
67
58
  id: "dashboard",
68
- meta: { route: "/dashboard", view: { component: "Dashboard" } },
59
+ meta: { route: "/dashboard" },
69
60
  initial: "overview",
70
61
  states: {
71
62
  overview: {
72
63
  id: "overview",
73
- meta: { route: "/overview", view: { component: "Overview" } },
64
+ meta: { route: "/overview" },
74
65
  },
75
66
  settings: {
76
67
  id: "settings",
77
- meta: { route: "/settings/:section?", view: { component: "Settings" } }, // Optional parameter
68
+ meta: { route: "/settings/:section?" }, // optional parameter
78
69
  },
79
70
  },
80
71
  },
72
+ profile: {
73
+ id: "profile",
74
+ meta: { route: "/profile/:userId" }, // required parameter
75
+ },
81
76
  },
82
77
  });
83
78
 
79
+ // Build hierarchical route tree with bidirectional maps
84
80
  const tree = extractMachineRoutes(machine);
85
81
 
86
- // Bidirectional lookup
87
- console.log(tree.byPath.get("/dashboard/overview")); // RouteNode
88
- console.log(tree.byId.get("overview")); // RouteNode
89
-
90
- // Pattern matching for dynamic routes
91
- const settingsRoute = tree.byPath.get("/settings/profile");
92
- console.log(settingsRoute?.id); // "settings"
93
- ```
94
-
95
- ## Vanilla Browser Example
82
+ // Path → RouteNode
83
+ const node = tree.byPath.get("/dashboard"); // RouteNode for "dashboard"
96
84
 
97
- See `examples/vanilla-demo/` for a complete example using vanilla TypeScript with the browser History API.
85
+ // State ID → RouteNode
86
+ const overview = tree.byStateId.get("overview");
87
+ console.log(overview?.fullPath); // "/overview"
98
88
 
99
- The demo demonstrates:
100
-
101
- - **RouteTree extraction** from XState machine meta.route
102
- - **History API integration** (pushState, popstate)
103
- - **Bidirectional synchronization** (actor ↔ URL)
104
- - **Protected route guards** (authentication redirects)
105
- - **Dynamic route parameters** (/profile/:userId)
106
-
107
- **Run the demo:**
108
-
109
- ```bash
110
- cd packages/play-router/examples/demo
111
- npm install
112
- npm run dev
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
- Open http://localhost:5174/ and explore:
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
- // Extract routes from machine
126
- const routeTree = extractMachineRoutes(authMachine);
127
- const routeMap = createRouteMap(routeTree);
128
-
129
- // Actor → URL sync
130
- const watcher = new Signal.subtle.Watcher(() => {
131
- queueMicrotask(() => {
132
- watcher.getPending();
133
- const route = actor.currentRoute.get();
134
- if (route) {
135
- window.history.pushState({}, "", route);
136
- }
137
- watcher.watch(actor.currentRoute);
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
- // URL → Actor sync (with pattern matching)
142
- window.addEventListener("popstate", () => {
143
- const path = window.location.pathname;
144
- const { to, params } = routeMap.resolve(path);
145
- if (to) {
146
- actor.send({ type: "play.route", to, params });
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 } = routeMap.resolve(initialPath);
154
- if (to) {
155
- actor.send({ type: "play.route", to, params });
156
- }
157
- }
158
121
  ```
159
122
 
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()`
123
+ ### Implementing a `RouterBridgeBase` adapter
171
124
 
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
-
182
- ## API Reference
183
-
184
- ### extractMachineRoutes()
185
-
186
- Main entry point — crawls state machine, extracts routes, builds tree:
125
+ Extend `RouterBridgeBase` and implement the three abstract methods for your framework:
187
126
 
188
127
  ```typescript
189
- const tree = extractMachineRoutes(machine: AnyStateMachine): RouteTree;
190
- ```
191
-
192
- **Detection:**
193
-
194
- - States with `meta.route` in meta object
195
-
196
- **Returns:** `RouteTree` with:
197
-
198
- - `routes: RouteNode[]` - Array of route nodes
199
- - `byPath: Map<string, RouteNode>` - URL path → route node
200
- - `byId: Map<string, RouteNode>` - State ID → route node
201
-
202
- **Throws:** Error if routes are invalid (malformed paths, missing state IDs, duplicates)
203
-
204
- **Example:**
205
-
206
- ```typescript
207
- import { extractMachineRoutes } from "@xmachines/play-router";
208
-
209
- const tree = extractMachineRoutes(authMachine);
128
+ import { RouterBridgeBase } from "@xmachines/play-router";
129
+ import type { RoutableActor } from "@xmachines/play-router";
130
+
131
+ export class MyRouterBridge extends RouterBridgeBase {
132
+ private unsubscribe: (() => void) | null = null;
133
+
134
+ constructor(
135
+ private readonly myRouter: MyRouter,
136
+ actor: RoutableActor,
137
+ routeMap: ReturnType<typeof createRouteMap>,
138
+ ) {
139
+ super(actor, routeMap);
140
+ }
210
141
 
211
- // Query routes
212
- const loginRoute = tree.byId.get("login");
213
- console.log(loginRoute?.path); // "/login"
142
+ // Tell the framework router to navigate to a path
143
+ protected navigateRouter(path: string): void {
144
+ this.myRouter.navigate(path);
145
+ }
214
146
 
215
- const dashboardRoute = tree.byPath.get("/dashboard");
216
- console.log(dashboardRoute?.id); // "dashboard"
217
- ```
147
+ // Subscribe to router location changes, call syncActorFromRouter on each
148
+ protected watchRouterChanges(): void {
149
+ this.unsubscribe = this.myRouter.subscribe((location) => {
150
+ this.syncActorFromRouter(location.pathname, location.search);
151
+ });
152
+ }
218
153
 
219
- ### crawlMachine()
154
+ // Unsubscribe from router location changes
155
+ protected unwatchRouterChanges(): void {
156
+ this.unsubscribe?.();
157
+ this.unsubscribe = null;
158
+ }
220
159
 
221
- Low-level BFS traversal of state machine graph:
160
+ // Provide the router's current path for initial deep-link sync
161
+ protected override getInitialRouterPath(): string {
162
+ return this.myRouter.state.location.pathname;
163
+ }
164
+ }
222
165
 
223
- ```typescript
224
- const visits = crawlMachine(machine: AnyStateMachine): StateVisit[];
166
+ // Usage
167
+ const routeMap = createRouteMap(machine);
168
+ const bridge = new MyRouterBridge(myRouter, actor, routeMap);
169
+ bridge.connect();
170
+ // ...
171
+ bridge.disconnect();
225
172
  ```
226
173
 
227
- **Returns:** Array of state visits in breadth-first order with:
174
+ ## API Summary
228
175
 
229
- - `path: string[]` - State path (e.g., ["dashboard", "settings"])
230
- - `parent: StateNode | null` - Parent state node
231
- - `node: StateNode` - Current state node
176
+ ### Route Extraction
232
177
 
233
- **Example:**
178
+ | Export | Description |
179
+ | ---------------------------------------- | ---------------------------------------------------------------------------------- |
180
+ | `extractMachineRoutes(machine)` | Convert an XState machine to a `RouteTree` with bidirectional state ID ↔ path maps |
181
+ | `createRouteMap(machine, options?)` | Build a `RouteMap` directly from a machine (preferred form for adapters) |
182
+ | `createRouteMapFromTree(tree, options?)` | Build a `RouteMap` from an already-extracted `RouteTree` |
183
+ | `buildRouteTree(routes)` | Build a `RouteTree` from an array of `RouteInfo` objects |
184
+ | `machineToGraph(machine)` | Convert a machine to a typed `@statelyai/graph` `Graph` for graph algorithm access |
234
185
 
235
- ```typescript
236
- import { crawlMachine } from "@xmachines/play-router";
186
+ ### Route Matching
237
187
 
238
- const visits = crawlMachine(machine);
239
- visits.forEach((visit) => {
240
- console.log("State:", visit.path.join("."));
241
- console.log("Parent:", visit.parent?.id ?? "root");
242
- });
243
- ```
188
+ | Export | Description |
189
+ | ----------------------------- | ------------------------------------------------------------------------------------------ |
190
+ | `RouteMap` | Bidirectional `stateId ↔ path` lookup class; supports O(1) exact and O(k) pattern matching |
191
+ | `findRouteById(tree, id)` | Look up a `RouteNode` by state ID |
192
+ | `findRouteByPath(tree, path)` | Look up a `RouteNode` by URL path (supports dynamic patterns) |
244
193
 
245
194
  ### Query Utilities
246
195
 
247
- ```typescript
248
- // Get child routes from state
249
- const children = getNavigableRoutes(tree, "dashboard");
250
-
251
- // Check if route exists
252
- const exists = routeExists(tree, "/profile/:userId");
253
- ```
254
-
255
- **Complete API:** See [API Documentation](../../docs/api/@xmachines/play-router)
256
-
257
- ## Examples
258
-
259
- ### Route Detection
260
-
261
- ```typescript
262
- import { extractMachineRoutes } from "@xmachines/play-router";
263
- import { createMachine } from "xstate";
264
-
265
- const machine = createMachine({
266
- initial: "home",
267
- states: {
268
- home: {
269
- id: "home",
270
- meta: { route: "/", view: { component: "Home" } },
271
- },
272
- profile: {
273
- id: "profile",
274
- meta: { route: "/profile/:userId", view: { component: "Profile" } }, // Parameter pattern
275
- },
276
- settings: {
277
- id: "settings",
278
- meta: { route: "/settings/:section?", view: { component: "Settings" } }, // Optional parameter
279
- },
280
- },
281
- });
282
-
283
- const tree = extractMachineRoutes(machine);
284
-
285
- // Bidirectional mapping
286
- const profileById = tree.byId.get("profile");
287
- console.log(profileById?.path); // "/profile/:userId"
288
-
289
- const profileByPath = tree.byPath.get("/profile/user123");
290
- console.log(profileByPath?.id); // "profile"
291
- ```
292
-
293
- ### Hierarchical Route Tree
196
+ | Export | Description |
197
+ | ------------------------------------------------- | ------------------------------------------------------------------------- |
198
+ | `getRoutableRoutes(tree)` | All routable `RouteNode`s as a flat array |
199
+ | `getNavigableRoutes(tree, stateId)` | Child routes reachable from a state (hierarchical + transition-reachable) |
200
+ | `routeExists(tree, path)` | Check whether a path is registered in the tree |
201
+ | `getTransitionReachableRoutes(graph, stateId)` | Route paths reachable via XState transitions from a state |
202
+ | `isRouteReachable(graph, fromStateId, toStateId)` | Check whether a transition path exists between two states |
203
+
204
+ ### Router Bridge
205
+
206
+ | Export | Description |
207
+ | --------------------------------------- | ------------------------------------------------------------------------------------- |
208
+ | `RouterBridgeBase` | Abstract base class for framework router adapters; implements `RouterBridge` protocol |
209
+ | `sanitizePathname(path)` | Normalize a raw pathname; returns `null` for paths > 2048 chars or malformed input |
210
+ | `buildPlayRouteEvent(options)` | Build a `PlayRouteEvent` from a pathname + route-map match result |
211
+ | `extractRouteParams(pathname, pattern)` | Extract path parameters from a URL using URLPattern |
212
+ | `extractQuery(search)` | Extract query parameters from a URL search string |
213
+
214
+ ### Validation
215
+
216
+ | Export | Description |
217
+ | ---------------------------------------- | ----------------------------------------------------- |
218
+ | `validateRouteFormat(route, stateId)` | Assert route path is non-empty |
219
+ | `validateStateExists(stateId, stateIds)` | Assert a state ID is present in the machine graph |
220
+ | `detectDuplicateRoutes(routes)` | Throw if any two states resolve to the same full path |
221
+
222
+ ### Key Types
223
+
224
+ | Export | Description |
225
+ | ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
226
+ | `RouterBridge` | Interface for `connect()` / `disconnect()` lifecycle |
227
+ | `RouteTree` | Hierarchical tree with `root`, `byStateId`, `byPath`, and optional `graph` |
228
+ | `RouteNode` | Single node in the tree with `id`, `path`, `fullPath`, `stateId`, `children`, `parent` |
229
+ | `RouteInfo` | Flat route descriptor extracted from a state node |
230
+ | `PlayRouteEvent` | Routing event `{ type: "play.route", to, params?, query? }` |
231
+ | `RoutableActor` | Minimal actor interface required by `RouterBridgeBase` — `currentRoute`, `initialRoute`, and `send(PlayRouteEvent)` |
232
+ | `PlayActor` | Full actor interface used by `PlayRouterProvider` — extends `RoutableActor` with `currentView` (`Routable + Viewable`) |
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 |
294
251
 
295
252
  ```typescript
296
- import { extractMachineRoutes, getNavigableRoutes } from "@xmachines/play-router";
297
-
298
- const machine = createMachine({
299
- initial: "app",
300
- states: {
301
- app: {
302
- id: "app",
303
- meta: { route: "/", view: { component: "AppShell" } },
304
- initial: "dashboard",
305
- states: {
306
- dashboard: {
307
- id: "dashboard",
308
- meta: { route: "/dashboard", view: { component: "Dashboard" } },
309
- initial: "overview",
310
- states: {
311
- overview: {
312
- id: "overview",
313
- meta: { route: "/overview", view: { component: "Overview" } },
314
- },
315
- analytics: {
316
- id: "analytics",
317
- meta: { route: "/analytics", view: { component: "Analytics" } },
318
- },
319
- },
320
- },
321
- },
322
- },
323
- },
324
- });
325
-
326
- const tree = extractMachineRoutes(machine);
327
-
328
- // Get child routes
329
- const dashboardChildren = getNavigableRoutes(tree, "dashboard");
330
- console.log(dashboardChildren.map((r) => r.id)); // ["overview", "analytics"]
331
-
332
- // Route inheritance
333
- const analyticsRoute = tree.byId.get("analytics");
334
- console.log(analyticsRoute?.path); // "/dashboard/analytics" (inherited parent path)
335
- ```
336
-
337
- ### Pattern Matching
338
-
339
- ```typescript
340
- import { extractMachineRoutes } from "@xmachines/play-router";
341
-
342
- const machine = createMachine({
343
- states: {
344
- user: {
345
- id: "user",
346
- meta: { route: "/user/:userId", view: { component: "User" } }, // Required parameter
347
- },
348
- settings: {
349
- id: "settings",
350
- meta: { route: "/settings/:section?", view: { component: "Settings" } }, // Optional parameter
351
- },
352
- },
353
- });
354
-
355
- const tree = extractMachineRoutes(machine);
356
-
357
- // Pattern matching for actual URLs
358
- const userRoute = tree.byPath.get("/user/user123");
359
- console.log(userRoute?.id); // "user"
360
-
361
- const settingsDefault = tree.byPath.get("/settings");
362
- console.log(settingsDefault?.id); // "settings" (optional param)
363
-
364
- const settingsProfile = tree.byPath.get("/settings/profile");
365
- console.log(settingsProfile?.id); // "settings" (with param)
253
+ import {
254
+ RouterSyncError,
255
+ DuplicateBridgeError,
256
+ URLPatternUnavailableError,
257
+ } from "@xmachines/play-router/errors";
258
+
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
+ }
366
268
  ```
367
269
 
368
270
  ## Route Configuration
369
271
 
370
- ### Route Pattern (Recommended)
272
+ ### `meta.route` patterns
371
273
 
372
- ```typescript
373
- states: {
374
- dashboard: {
375
- id: "dashboard", // Required for bidirectional lookup
376
- meta: {
377
- route: "/dashboard", // URL path - marks state as routable
378
- },
379
- },
380
- }
381
- ```
382
-
383
- ### Alternative Pattern
274
+ Routes are declared on XState state nodes via the `meta.route` field:
384
275
 
385
276
  ```typescript
386
277
  states: {
387
- dashboard: {
388
- id: "dashboard",
389
- meta: {
390
- route: "/dashboard",
391
- },
392
- },
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
+ },
393
294
  }
394
295
  ```
395
296
 
396
- ### Route Inheritance
297
+ ### Relative vs absolute paths
298
+
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:
397
300
 
398
301
  ```typescript
399
302
  states: {
400
- parent: {
401
- id: "parent",
402
- meta: { route: "/parent", view: { component: "Parent" } },
403
- states: {
404
- absolute: {
405
- id: "absolute",
406
- meta: { route: "/absolute", view: { component: "Absolute" } }, // Starts with / → doesn't inherit
407
- },
408
- relative: {
409
- id: "relative",
410
- meta: { route: "relative", view: { component: "Relative" } }, // No leading / → inherits parent
411
- // Final path: "/parent/relative"
412
- },
413
- },
414
- },
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
+ },
415
317
  }
416
318
  ```
417
319
 
418
- ### Dynamic Parameters
320
+ Always use `node.fullPath` (never `node.path`) for browser URL matching and route map construction.
419
321
 
420
- ```typescript
421
- meta: {
422
- route: "/profile/:userId", // Required parameter
423
- route: "/settings/:section?", // Optional parameter
424
- route: "/docs/:category/:page", // Multiple parameters
425
- view: { component: "AnyView" },
426
- }
427
- ```
322
+ ## Testing
428
323
 
429
- **Parameter substitution:** Values extracted from context or event params (handled by play-xstate adapter).
430
-
431
- ## Architecture
324
+ ```bash
325
+ # Run tests for this package
326
+ pnpm --filter @xmachines/play-router test
432
327
 
433
- This package enables **Actor Authority (INV-01)**:
328
+ # Watch mode
329
+ pnpm --filter @xmachines/play-router run test:watch
330
+ ```
434
331
 
435
- 1. **Routes derive from machine:** Business logic defines routes in state machine, not external config
436
- 2. **BFS traversal:** Systematic state discovery ensures all nested states visited
437
- 3. **Bidirectional mapping:** Fast lookup by path (browser URL) or by ID (state machine)
438
- 4. **Build-time validation:** Invalid routes throw errors during extraction, not runtime
332
+ A router bridge contract test suite for adapter authors lives in
333
+ `@xmachines/play-router-shared` (it drives a real actor, so it sits one layer
334
+ above this package to keep `@xmachines/play-router` free of an actor-runtime
335
+ dependency):
439
336
 
440
- **Enhancements:**
337
+ ```typescript
338
+ import { runBridgeContractTests } from "@xmachines/play-router-shared/test/router-bridge-contract.js";
441
339
 
442
- - `meta.route` detection via state metadata
443
- - Pattern matching for dynamic routes (`:param` and `:param?`)
444
- - State ID ↔ path bidirectional maps for `play.route` events
340
+ runBridgeContractTests({
341
+ name: "MyRouterBridge",
342
+ createHarness(initialPath) {
343
+ // return ContractHarness with bridge, actor, simulateNavigation, getLastNavigatedPath
344
+ },
345
+ });
346
+ ```
445
347
 
446
348
  ## Related Packages
447
349
 
448
- - **[@xmachines/play-xstate](../play-xstate)** - XState adapter using route extraction
449
- - **[@xmachines/play-tanstack-react-router](../play-tanstack-react-router)** - TanStack Router adapter using route trees
450
- - **[@xmachines/play-react-router](../play-react-router)** - React Router v7 adapter using RouterBridgeBase
451
- - **[@xmachines/play](../play)** - Protocol types
350
+ - **[@xmachines/play](../play/README.md)** — Core protocol types (`PlayEvent`, `PlayError`)
351
+ - **[@xmachines/play-actor](../play-actor/README.md)** — Abstract actor base class (`AbstractActor`, `Routable`); all `AbstractActor` subclasses satisfy `RoutableActor` structurally
352
+ - **[@xmachines/play-signals](../play-signals/README.md)** — TC39 Signals polyfill used for actor route observation
353
+ - **[@xmachines/play-xstate](../play-xstate/README.md)** — XState v5 logic adapter that integrates with route trees
354
+ - **[@xmachines/play-tanstack-router](../play-tanstack-router/README.md)** — Shared TanStack Router bridge base (framework-agnostic)
355
+ - **[@xmachines/play-tanstack-react-router](../play-tanstack-react-router/README.md)** — TanStack Router adapter (React)
356
+ - **[@xmachines/play-tanstack-solid-router](../play-tanstack-solid-router/README.md)** — TanStack Router adapter (SolidJS)
357
+ - **[@xmachines/play-react-router](../play-react-router/README.md)** — React Router v7 adapter
358
+ - **[@xmachines/play-vue-router](../play-vue-router/README.md)** — Vue Router adapter
359
+ - **[@xmachines/play-solid-router](../play-solid-router/README.md)** — SolidJS Router adapter
452
360
 
453
361
  ## License
454
362
 
455
- Copyright (c) 2016 [Mikael Karon](mailto:mikael@karon.se). All rights reserved.
456
-
457
- This work is licensed under the terms of the MIT license.
458
- For a copy, see <https://opensource.org/licenses/MIT>.
363
+ MIT — see [LICENSE](LICENSE).