@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.
Files changed (2) hide show
  1. package/README.md +235 -475
  2. 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
- **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
7
  Graph-based route extraction and bidirectional lookup enabling Actor Authority over navigation.
6
8
 
7
- ## Overview
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.0.0
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.0.0 — State machine runtime
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 (Node.js < 24, older browsers).
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
- ## Quick Start
43
+ ### Extract routes from a machine
54
44
 
55
45
  ```typescript
56
46
  import { createMachine } from "xstate";
57
- import { extractMachineRoutes, findRouteByPath } from "@xmachines/play-router";
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: "/", view: { component: "Home" } },
55
+ meta: { route: "/" },
67
56
  },
68
57
  dashboard: {
69
58
  id: "dashboard",
70
- meta: { route: "/dashboard", view: { component: "Dashboard" } },
59
+ meta: { route: "/dashboard" },
71
60
  initial: "overview",
72
61
  states: {
73
62
  overview: {
74
63
  id: "overview",
75
- meta: { route: "/overview", view: { component: "Overview" } },
64
+ meta: { route: "/overview" },
76
65
  },
77
66
  settings: {
78
67
  id: "settings",
79
- meta: { route: "/settings/:section?", view: { component: "Settings" } }, // Optional parameter
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
- // Bidirectional lookup
89
- console.log(findRouteByPath(tree, "/overview")); // RouteNode
90
- console.log(tree.byStateId.get("overview")); // RouteNode
82
+ // Path → RouteNode
83
+ const node = tree.byPath.get("/dashboard"); // RouteNode for "dashboard"
91
84
 
92
- // Pattern matching for dynamic routes
93
- const settingsRoute = findRouteByPath(tree, "/settings/profile");
94
- console.log(settingsRoute?.id); // "settings"
95
- ```
85
+ // State ID RouteNode
86
+ const overview = tree.byStateId.get("overview");
87
+ console.log(overview?.fullPath); // "/overview"
96
88
 
97
- ## Vanilla Browser Example
98
-
99
- See `examples/vanilla-demo/` for a complete example using vanilla TypeScript with the browser History API.
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
- 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 routeMatcher = createRouteMatcher(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 } = routeMatcher.match(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 } = 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
- **Detection:**
123
+ ### Implementing a `RouterBridgeBase` adapter
221
124
 
222
- - States with `meta.route` in meta object
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 { machineToGraph } from "@xmachines/play-router";
261
- import { getChildren, getSuccessors, hasPath } from "@statelyai/graph";
262
-
263
- const graph = machineToGraph(machine);
264
-
265
- // Hierarchy queries
266
- const children = getChildren(graph, "myMachine");
267
- const successors = getSuccessors(graph, "myMachine.home"); // transition-reachable states
268
-
269
- // Reachability
270
- const reachable = hasPath(graph, "myMachine.home", "myMachine.dashboard");
271
- ```
272
-
273
- The graph is also available on any `RouteTree` returned by `extractMachineRoutes()`:
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
- **Edge data** (`MachineEdgeData`):
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
- - `eventType: string` event type triggering this transition
292
- - `guardType?: string` — guard name/description (if transition is guarded)
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
- ### createRouteMap() / createRouteMapFromTree()
155
+ // Unsubscribe from router location changes
156
+ protected unwatchRouterChanges(): void {
157
+ this.unsubscribe?.();
158
+ this.unsubscribe = null;
159
+ }
295
160
 
296
- Both build a `BaseRouteMap` using `node.fullPath` (absolute resolved paths) for browser URL matching.
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
- ```typescript
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
- // Two-step form — used by framework adapter packages:
304
- import { extractMachineRoutes, createRouteMapFromTree } from "@xmachines/play-router";
305
- const routeTree = extractMachineRoutes(machine);
306
- const routeMap = createRouteMapFromTree(routeTree);
169
+ const bridge = new MyRouterBridge(myRouter, actor, routeMap);
170
+ bridge.connect();
171
+ // ...
172
+ bridge.disconnect();
307
173
  ```
308
174
 
309
- Both forms produce identical `RouteMap` results. `createRouteMapFromTree` is exposed for framework adapters (React Router, TanStack Router) that already hold the `RouteTree` separately for other purposes (e.g. graph queries). `createRouteMatcher(routeTree)` is the distinct API for URL-pattern matching that returns a `RouteMatcher`, not a `RouteMap`.
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
- ```typescript
316
- import {
317
- getNavigableRoutes,
318
- getRoutableRoutes,
319
- routeExists,
320
- getTransitionReachableRoutes,
321
- isRouteReachable,
322
- } from "@xmachines/play-router";
177
+ ### Route Extraction
323
178
 
324
- // Child routes (hierarchical + transition-reachable)
325
- const navigable = getNavigableRoutes(tree, "dashboard");
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
- // All routable routes as flat array
328
- const allRoutes = getRoutableRoutes(tree);
187
+ ### Route Matching
329
188
 
330
- // Check path exists
331
- const exists = routeExists(tree, "/profile/:userId");
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
- // Transition-aware: which state IDs are reachable via transitions?
334
- const reachableIds = getTransitionReachableRoutes(tree.graph!, "myMachine.home");
335
-
336
- // Is there any path from state A to state B via transitions?
337
- const canReach = isRouteReachable(tree.graph!, "myMachine.home", "myMachine.dashboard");
338
- ```
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
- ### Pattern Matching
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 { extractMachineRoutes, findRouteByPath } from "@xmachines/play-router";
424
-
425
- const machine = createMachine({
426
- states: {
427
- user: {
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
- const settingsProfile = findRouteByPath(tree, "/settings/profile");
448
- console.log(settingsProfile?.id); // "settings" (with param)
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
- ### Route Pattern (Recommended)
272
+ ### `meta.route` patterns
454
273
 
455
- ```typescript
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
- dashboard: {
471
- id: "dashboard",
472
- meta: {
473
- route: "/dashboard",
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
- ### Route Inheritance
297
+ ### Relative vs absolute paths
480
298
 
481
- ```typescript
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
- meta: {
505
- route: "/profile/:userId", // Required parameter
506
- route: "/settings/:section?", // Optional parameter
507
- route: "/docs/:category/:page", // Multiple parameters
508
- view: { component: "AnyView" },
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
- **Parameter substitution:** Values extracted from context or event params (handled by play-xstate adapter).
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
- Normalize a raw pathname before route-map lookup. Used internally by `RouterBridgeBase.syncActorFromRouter()` and available to adapters that bypass the base class:
322
+ ## Testing
519
323
 
520
- ```typescript
521
- import { sanitizePathname } from "@xmachines/play-router";
522
-
523
- // In a custom adapter's route-watch callback:
524
- const clean = sanitizePathname(rawPath);
525
- if (clean === null) return; // Reject malformed/oversized paths (> 2048 chars)
526
- // Proceed with clean normalized path
527
- ```
528
-
529
- Returns `null` for paths exceeding 2048 characters. Strips query fragments, hash fragments, and collapses duplicate slashes.
530
-
531
- ---
532
-
533
- ## Error Handling
534
-
535
- All runtime errors thrown by this package extend `PlayError` from `@xmachines/play` and
536
- are exported from the `./errors` subpath:
324
+ ```bash
325
+ # Run tests for this package
326
+ npm test -w @xmachines/play-router
537
327
 
538
- ```typescript
539
- import {
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
- | Class | Code | When thrown |
547
- | ---------------------------- | --------------------------------------- | -------------------------------------------------------------- |
548
- | `RouterSyncError` | `PLAY_ROUTER_SYNC_FAILED` | `syncActorFromRouter()` fails to send a `play.route` event |
549
- | `URLPatternUnavailableError` | `PLAY_ROUTE_MAP_URLPATTERN_UNAVAILABLE` | `createRouteMap()` called before URLPattern polyfill is loaded |
550
- | `InvalidRoutePatternError` | `PLAY_ROUTE_MAP_INVALID_PATTERN` | A route pattern string is rejected by URLPattern constructor |
551
-
552
- `InvalidRoutePatternError` carries a `pattern: string` field with the offending pattern.
332
+ The package exports a router bridge contract test suite for adapter authors:
553
333
 
554
334
  ```typescript
555
- import { PlayError } from "@xmachines/play";
556
- import { RouterSyncError } from "@xmachines/play-router/errors";
335
+ import { runBridgeContractTests } from "@xmachines/play-router/test/contract.js";
557
336
 
558
- try {
559
- bridge.connect();
560
- } catch (err) {
561
- if (err instanceof RouterSyncError) {
562
- // err.cause — the original error that triggered the sync failure
563
- monitoringService.record(err);
564
- } else if (err instanceof PlayError) {
565
- console.error(`[${err.scope}:${err.code}] ${err.message}`);
566
- }
567
- }
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-xstate](../play-xstate/README.md)** - XState adapter using route extraction
590
- - **[@xmachines/play-tanstack-react-router](../play-tanstack-react-router/README.md)** - TanStack Router adapter using route trees
591
- - **[@xmachines/play-react-router](../play-react-router/README.md)** - React Router v7 adapter using RouterBridgeBase
592
- - **[@xmachines/play](../play/README.md)** - Protocol types
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
- Copyright (c) 2016 [Mikael Karon](mailto:mikael@karon.se). All rights reserved.
597
-
598
- This work is licensed under the terms of the MIT license.
599
- For a copy, see <https://opensource.org/licenses/MIT>.
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.46",
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.11.0",
50
- "@xmachines/play": "1.0.0-beta.46",
51
- "@xmachines/play-actor": "1.0.0-beta.46",
52
- "@xmachines/play-signals": "1.0.0-beta.46",
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.46",
58
- "@xmachines/shared": "1.0.0-beta.46",
59
- "oxfmt": "^0.45.0",
60
- "oxlint": "^1.60.0",
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.4",
64
- "xstate": "^5.30.0"
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.30.0"
68
+ "xstate": "^5.31.0"
69
69
  },
70
70
  "peerDependenciesMeta": {
71
71
  "urlpattern-polyfill": {