@xmachines/play-router 1.0.0-beta.8 → 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 +259 -335
  3. package/dist/base-route-map.d.ts +136 -0
  4. package/dist/base-route-map.d.ts.map +1 -0
  5. package/dist/base-route-map.js +182 -0
  6. package/dist/base-route-map.js.map +1 -0
  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 +29 -33
  16. package/dist/create-route-map.d.ts.map +1 -1
  17. package/dist/create-route-map.js +25 -64
  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 -51
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/index.js +13 -133
  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 +196 -32
  44. package/dist/router-bridge-base.d.ts.map +1 -1
  45. package/dist/router-bridge-base.js +319 -85
  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 +42 -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 -119
  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 -68
  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,439 +1,363 @@
1
+ <!-- generated-by: gsd-doc-writer -->
2
+
1
3
  # @xmachines/play-router
2
4
 
3
- **Route tree extraction from XState v5 state machines with routing patterns**
5
+ Route tree extraction from XState v5 state machines. Part of [@xmachines/play](../play/README.md) Universal Player Architecture.
4
6
 
5
- BFS graph crawling and bidirectional route lookup enabling Actor Authority over navigation.
7
+ Graph-based route extraction and bidirectional lookup enabling Actor Authority over navigation.
6
8
 
7
- ## Overview
9
+ Part of the [xmachines-js monorepo](../../README.md).
8
10
 
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.
11
+ ## Installation
10
12
 
11
- It also exports `RouterBridgeBase`, the shared base class used by framework adapters to implement `RouterBridge` with consistent actor↔router synchronization behavior.
13
+ ```bash
14
+ pnpm add xstate@^5.31.0
15
+ pnpm add @xmachines/play-router
16
+ ```
12
17
 
13
- `RouterBridgeBase` is the policy point; framework adapters are thin ports that implement only framework-specific navigate/subscribe/unsubscribe behavior.
18
+ **Peer dependencies:**
14
19
 
15
- Per [RFC Play v1](https://gitlab.com/xmachin-es/rfc/-/blob/main/src/play-v1.md), this package implements:
20
+ - `xstate` ^5.31.0 — XState v5 state machine runtime
16
21
 
17
- - **Actor Authority (INV-01):** Routes derive from machine definitions, not external configuration
22
+ **URLPattern polyfill (Node.js < 24 / older browsers):**
18
23
 
19
- **Routing:** Supports `meta.route` detection, `play.route` event routing, and pattern matching for dynamic parameters.
24
+ `@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+).
20
25
 
21
- ## Installation
26
+ On environments without native support, load a polyfill **before** importing this package:
27
+
28
+ ```typescript
29
+ // Entry point — must run before any @xmachines/play-router import
30
+ import "urlpattern-polyfill";
31
+ ```
32
+
33
+ Install the polyfill:
22
34
 
23
35
  ```bash
24
- npm install xstate@^5.0.0
25
- npm install @xmachines/play-router
36
+ pnpm add urlpattern-polyfill
26
37
  ```
27
38
 
28
- **Peer dependencies:**
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.
29
40
 
30
- - `xstate` ^5.0.0 - State machine runtime
41
+ ## Usage
31
42
 
32
- ## Quick Start
43
+ ### Extract routes from a machine
33
44
 
34
45
  ```typescript
35
46
  import { createMachine } from "xstate";
36
- import { extractMachineRoutes } from "@xmachines/play-router";
47
+ import { extractMachineRoutes, createRouteMap } from "@xmachines/play-router";
37
48
 
38
- // Route pattern (recommended)
39
49
  const machine = createMachine({
40
50
  id: "app",
41
51
  initial: "home",
42
52
  states: {
43
53
  home: {
44
54
  id: "home",
45
- meta: { route: "/", view: { component: "Home" } },
55
+ meta: { route: "/" },
46
56
  },
47
57
  dashboard: {
48
58
  id: "dashboard",
49
- meta: { route: "/dashboard", view: { component: "Dashboard" } },
59
+ meta: { route: "/dashboard" },
50
60
  initial: "overview",
51
61
  states: {
52
62
  overview: {
53
63
  id: "overview",
54
- meta: { route: "/overview", view: { component: "Overview" } },
64
+ meta: { route: "/overview" },
55
65
  },
56
66
  settings: {
57
67
  id: "settings",
58
- meta: { route: "/settings/:section?", view: { component: "Settings" } }, // Optional parameter
68
+ meta: { route: "/settings/:section?" }, // optional parameter
59
69
  },
60
70
  },
61
71
  },
72
+ profile: {
73
+ id: "profile",
74
+ meta: { route: "/profile/:userId" }, // required parameter
75
+ },
62
76
  },
63
77
  });
64
78
 
79
+ // Build hierarchical route tree with bidirectional maps
65
80
  const tree = extractMachineRoutes(machine);
66
81
 
67
- // Bidirectional lookup
68
- console.log(tree.byPath.get("/dashboard/overview")); // RouteNode
69
- console.log(tree.byId.get("overview")); // RouteNode
70
-
71
- // Pattern matching for dynamic routes
72
- const settingsRoute = tree.byPath.get("/settings/profile");
73
- console.log(settingsRoute?.id); // "settings"
74
- ```
75
-
76
- ## Vanilla Browser Example
77
-
78
- See `examples/vanilla-demo/` for a complete example using vanilla TypeScript with the browser History API.
82
+ // Path → RouteNode
83
+ const node = tree.byPath.get("/dashboard"); // RouteNode for "dashboard"
79
84
 
80
- The demo demonstrates:
85
+ // State ID → RouteNode
86
+ const overview = tree.byStateId.get("overview");
87
+ console.log(overview?.fullPath); // "/overview"
81
88
 
82
- - **RouteTree extraction** from XState machine meta.route
83
- - **History API integration** (pushState, popstate)
84
- - **Bidirectional synchronization** (actor ↔ URL)
85
- - **Protected route guards** (authentication redirects)
86
- - **Dynamic route parameters** (/profile/:userId)
87
-
88
- **Run the demo:**
89
-
90
- ```bash
91
- cd packages/play-router/examples/demo
92
- npm install
93
- 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"
94
93
  ```
95
94
 
96
- Open http://localhost:5174/ and explore:
97
-
98
- 1. Login with any username
99
- 2. Navigate between home and profile
100
- 3. Use browser back/forward buttons
101
- 4. Try accessing protected routes directly
102
-
103
- **Key implementation patterns:**
95
+ ### Sending `play.route` events
104
96
 
105
97
  ```typescript
106
- // Extract routes from machine
107
- const routeTree = extractMachineRoutes(authMachine);
108
- const routeMap = createRouteMap(routeTree);
109
-
110
- // Actor → URL sync
111
- const watcher = new Signal.subtle.Watcher(() => {
112
- queueMicrotask(() => {
113
- watcher.getPending();
114
- const route = actor.currentRoute.get();
115
- if (route) {
116
- window.history.pushState({}, "", route);
117
- }
118
- watcher.watch(actor.currentRoute);
119
- });
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" },
120
112
  });
121
113
 
122
- // URL → Actor sync (with pattern matching)
123
- window.addEventListener("popstate", () => {
124
- const path = window.location.pathname;
125
- const { to, params } = routeMap.resolve(path);
126
- if (to) {
127
- actor.send({ type: "play.route", to, params });
128
- }
114
+ // Navigate with query parameters
115
+ actor.send({
116
+ type: "play.route",
117
+ to: "#settings",
118
+ params: { section: "billing" },
119
+ query: { tab: "invoices" },
129
120
  });
130
-
131
- // Initial URL handling
132
- const initialPath = window.location.pathname;
133
- if (initialPath !== "/") {
134
- const { to, params } = routeMap.resolve(initialPath);
135
- if (to) {
136
- actor.send({ type: "play.route", to, params });
137
- }
138
- }
139
121
  ```
140
122
 
141
- This example shows the core routing concepts without framework dependencies, making it ideal for understanding how @xmachines/play-router integrates with browser History API.
142
-
143
- ## Canonical Watcher Lifecycle
144
-
145
- Bridge implementations should use one watcher flow:
146
-
147
- 1. `notify`
148
- 2. `queueMicrotask`
149
- 3. `getPending()`
150
- 4. read actor route and sync infrastructure state
151
- 5. re-arm with `watch(...)` or `watch()`
152
-
153
- Watcher notification is one-shot; re-arm is required.
154
-
155
- ## Bridge Cleanup Contract
156
-
157
- Bridge teardown must be explicit and deterministic:
158
-
159
- - `disconnect`/`dispose` must unwatch signal subscriptions and unhook router listeners.
160
- - Do not rely on GC-only cleanup guidance.
161
- - Infrastructure remains passive: bridges observe and forward intents, actors decide validity.
162
-
163
- ## API Reference
164
-
165
- ### extractMachineRoutes()
123
+ ### Implementing a `RouterBridgeBase` adapter
166
124
 
167
- Main entry point — crawls state machine, extracts routes, builds tree:
125
+ Extend `RouterBridgeBase` and implement the three abstract methods for your framework:
168
126
 
169
127
  ```typescript
170
- const tree = extractMachineRoutes(machine: AnyStateMachine): RouteTree;
171
- ```
172
-
173
- **Detection:**
174
-
175
- - States with `meta.route` in meta object
176
-
177
- **Returns:** `RouteTree` with:
178
-
179
- - `routes: RouteNode[]` - Array of route nodes
180
- - `byPath: Map<string, RouteNode>` - URL path → route node
181
- - `byId: Map<string, RouteNode>` - State ID → route node
182
-
183
- **Throws:** Error if routes are invalid (malformed paths, missing state IDs, duplicates)
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
+ }
184
141
 
185
- **Example:**
142
+ // Tell the framework router to navigate to a path
143
+ protected navigateRouter(path: string): void {
144
+ this.myRouter.navigate(path);
145
+ }
186
146
 
187
- ```typescript
188
- import { extractMachineRoutes } from "@xmachines/play-router";
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
+ }
189
153
 
190
- const tree = extractMachineRoutes(authMachine);
154
+ // Unsubscribe from router location changes
155
+ protected unwatchRouterChanges(): void {
156
+ this.unsubscribe?.();
157
+ this.unsubscribe = null;
158
+ }
191
159
 
192
- // Query routes
193
- const loginRoute = tree.byId.get("login");
194
- console.log(loginRoute?.path); // "/login"
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
+ }
195
165
 
196
- const dashboardRoute = tree.byPath.get("/dashboard");
197
- console.log(dashboardRoute?.id); // "dashboard"
166
+ // Usage
167
+ const routeMap = createRouteMap(machine);
168
+ const bridge = new MyRouterBridge(myRouter, actor, routeMap);
169
+ bridge.connect();
170
+ // ...
171
+ bridge.disconnect();
198
172
  ```
199
173
 
200
- ### crawlMachine()
174
+ ## API Summary
201
175
 
202
- Low-level BFS traversal of state machine graph:
176
+ ### Route Extraction
203
177
 
204
- ```typescript
205
- const visits = crawlMachine(machine: AnyStateMachine): StateVisit[];
206
- ```
207
-
208
- **Returns:** Array of state visits in breadth-first order with:
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 |
209
185
 
210
- - `path: string[]` - State path (e.g., ["dashboard", "settings"])
211
- - `parent: StateNode | null` - Parent state node
212
- - `node: StateNode` - Current state node
186
+ ### Route Matching
213
187
 
214
- **Example:**
215
-
216
- ```typescript
217
- import { crawlMachine } from "@xmachines/play-router";
218
-
219
- const visits = crawlMachine(machine);
220
- visits.forEach((visit) => {
221
- console.log("State:", visit.path.join("."));
222
- console.log("Parent:", visit.parent?.id ?? "root");
223
- });
224
- ```
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) |
225
193
 
226
194
  ### Query Utilities
227
195
 
228
- ```typescript
229
- // Get child routes from state
230
- const children = getNavigableRoutes(tree, "dashboard");
231
-
232
- // Check if route exists
233
- const exists = routeExists(tree, "/profile/:userId");
234
- ```
235
-
236
- **Complete API:** See [API Documentation](../../docs/api/@xmachines/play-router)
237
-
238
- ## Examples
239
-
240
- ### Route Detection
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 |
241
251
 
242
252
  ```typescript
243
- import { extractMachineRoutes } from "@xmachines/play-router";
244
- import { createMachine } from "xstate";
245
-
246
- const machine = createMachine({
247
- initial: "home",
248
- states: {
249
- home: {
250
- id: "home",
251
- meta: { route: "/", view: { component: "Home" } },
252
- },
253
- profile: {
254
- id: "profile",
255
- meta: { route: "/profile/:userId", view: { component: "Profile" } }, // Parameter pattern
256
- },
257
- settings: {
258
- id: "settings",
259
- meta: { route: "/settings/:section?", view: { component: "Settings" } }, // Optional parameter
260
- },
261
- },
262
- });
263
-
264
- const tree = extractMachineRoutes(machine);
265
-
266
- // Bidirectional mapping
267
- const profileById = tree.byId.get("profile");
268
- console.log(profileById?.path); // "/profile/:userId"
269
-
270
- const profileByPath = tree.byPath.get("/profile/user123");
271
- console.log(profileByPath?.id); // "profile"
272
- ```
273
-
274
- ### Hierarchical Route Tree
275
-
276
- ```typescript
277
- import { extractMachineRoutes, getNavigableRoutes } from "@xmachines/play-router";
278
-
279
- const machine = createMachine({
280
- initial: "app",
281
- states: {
282
- app: {
283
- id: "app",
284
- meta: { route: "/", view: { component: "AppShell" } },
285
- initial: "dashboard",
286
- states: {
287
- dashboard: {
288
- id: "dashboard",
289
- meta: { route: "/dashboard", view: { component: "Dashboard" } },
290
- initial: "overview",
291
- states: {
292
- overview: {
293
- id: "overview",
294
- meta: { route: "/overview", view: { component: "Overview" } },
295
- },
296
- analytics: {
297
- id: "analytics",
298
- meta: { route: "/analytics", view: { component: "Analytics" } },
299
- },
300
- },
301
- },
302
- },
303
- },
304
- },
305
- });
306
-
307
- const tree = extractMachineRoutes(machine);
308
-
309
- // Get child routes
310
- const dashboardChildren = getNavigableRoutes(tree, "dashboard");
311
- console.log(dashboardChildren.map((r) => r.id)); // ["overview", "analytics"]
312
-
313
- // Route inheritance
314
- const analyticsRoute = tree.byId.get("analytics");
315
- console.log(analyticsRoute?.path); // "/dashboard/analytics" (inherited parent path)
316
- ```
317
-
318
- ### Pattern Matching
319
-
320
- ```typescript
321
- import { extractMachineRoutes } from "@xmachines/play-router";
322
-
323
- const machine = createMachine({
324
- states: {
325
- user: {
326
- id: "user",
327
- meta: { route: "/user/:userId", view: { component: "User" } }, // Required parameter
328
- },
329
- settings: {
330
- id: "settings",
331
- meta: { route: "/settings/:section?", view: { component: "Settings" } }, // Optional parameter
332
- },
333
- },
334
- });
335
-
336
- const tree = extractMachineRoutes(machine);
337
-
338
- // Pattern matching for actual URLs
339
- const userRoute = tree.byPath.get("/user/user123");
340
- console.log(userRoute?.id); // "user"
341
-
342
- const settingsDefault = tree.byPath.get("/settings");
343
- console.log(settingsDefault?.id); // "settings" (optional param)
344
-
345
- const settingsProfile = tree.byPath.get("/settings/profile");
346
- 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
+ }
347
268
  ```
348
269
 
349
270
  ## Route Configuration
350
271
 
351
- ### Route Pattern (Recommended)
272
+ ### `meta.route` patterns
352
273
 
353
- ```typescript
354
- states: {
355
- dashboard: {
356
- id: "dashboard", // Required for bidirectional lookup
357
- meta: {
358
- route: "/dashboard", // URL path - marks state as routable
359
- },
360
- },
361
- }
362
- ```
363
-
364
- ### Alternative Pattern
274
+ Routes are declared on XState state nodes via the `meta.route` field:
365
275
 
366
276
  ```typescript
367
277
  states: {
368
- dashboard: {
369
- id: "dashboard",
370
- meta: {
371
- route: "/dashboard",
372
- },
373
- },
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
+ },
374
294
  }
375
295
  ```
376
296
 
377
- ### 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:
378
300
 
379
301
  ```typescript
380
302
  states: {
381
- parent: {
382
- id: "parent",
383
- meta: { route: "/parent", view: { component: "Parent" } },
384
- states: {
385
- absolute: {
386
- id: "absolute",
387
- meta: { route: "/absolute", view: { component: "Absolute" } }, // Starts with / → doesn't inherit
388
- },
389
- relative: {
390
- id: "relative",
391
- meta: { route: "relative", view: { component: "Relative" } }, // No leading / → inherits parent
392
- // Final path: "/parent/relative"
393
- },
394
- },
395
- },
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
+ },
396
317
  }
397
318
  ```
398
319
 
399
- ### Dynamic Parameters
320
+ Always use `node.fullPath` (never `node.path`) for browser URL matching and route map construction.
400
321
 
401
- ```typescript
402
- meta: {
403
- route: "/profile/:userId", // Required parameter
404
- route: "/settings/:section?", // Optional parameter
405
- route: "/docs/:category/:page", // Multiple parameters
406
- view: { component: "AnyView" },
407
- }
408
- ```
322
+ ## Testing
409
323
 
410
- **Parameter substitution:** Values extracted from context or event params (handled by play-xstate adapter).
411
-
412
- ## Architecture
324
+ ```bash
325
+ # Run tests for this package
326
+ pnpm --filter @xmachines/play-router test
413
327
 
414
- This package enables **Actor Authority (INV-01)**:
328
+ # Watch mode
329
+ pnpm --filter @xmachines/play-router run test:watch
330
+ ```
415
331
 
416
- 1. **Routes derive from machine:** Business logic defines routes in state machine, not external config
417
- 2. **BFS traversal:** Systematic state discovery ensures all nested states visited
418
- 3. **Bidirectional mapping:** Fast lookup by path (browser URL) or by ID (state machine)
419
- 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):
420
336
 
421
- **Enhancements:**
337
+ ```typescript
338
+ import { runBridgeContractTests } from "@xmachines/play-router-shared/test/router-bridge-contract.js";
422
339
 
423
- - `meta.route` detection via state metadata
424
- - Pattern matching for dynamic routes (`:param` and `:param?`)
425
- - 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
+ ```
426
347
 
427
348
  ## Related Packages
428
349
 
429
- - **[@xmachines/play-xstate](../play-xstate)** - XState adapter using route extraction
430
- - **[@xmachines/play-tanstack-react-router](../play-tanstack-react-router)** - TanStack Router adapter using route trees
431
- - **[@xmachines/play-react-router](../play-react-router)** - React Router v7 adapter using RouterBridgeBase
432
- - **[@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
433
360
 
434
361
  ## License
435
362
 
436
- Copyright (c) 2016 [Mikael Karon](mailto:mikael@karon.se). All rights reserved.
437
-
438
- This work is licensed under the terms of the MIT license.
439
- For a copy, see <https://opensource.org/licenses/MIT>.
363
+ MIT — see [LICENSE](LICENSE).