@xmachines/play-router
API / @xmachines/play-router
Route tree extraction from XState v5 state machines. Part of @xmachines/play Universal Player Architecture.
Graph-based route extraction and bidirectional lookup enabling Actor Authority over navigation.
Part of the xmachines-js monorepo.
Installation
npm install xstate@^5.31.0npm install @xmachines/play-routerPeer dependencies:
xstate^5.31.0 — XState v5 state machine runtime
URLPattern polyfill (Node.js < 24 / older browsers):
@xmachines/play-router uses the URLPattern API for dynamic route matching. URLPattern is available natively on Node.js 24+ and modern browsers (Chrome 95+, Firefox 117+, Safari 16.4+).
On environments without native support, load a polyfill before importing this package:
// Entry point — must run before any @xmachines/play-router importimport "urlpattern-polyfill";Install the polyfill:
npm install urlpattern-polyfillurlpattern-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.
Usage
Extract routes from a machine
import { createMachine } from "xstate";import { extractMachineRoutes, createRouteMap } from "@xmachines/play-router";
const machine = createMachine({ id: "app", initial: "home", states: { home: { id: "home", meta: { route: "/" }, }, dashboard: { id: "dashboard", meta: { route: "/dashboard" }, initial: "overview", states: { overview: { id: "overview", meta: { route: "/overview" }, }, settings: { id: "settings", meta: { route: "/settings/:section?" }, // optional parameter }, }, }, profile: { id: "profile", meta: { route: "/profile/:userId" }, // required parameter }, },});
// Build hierarchical route tree with bidirectional mapsconst tree = extractMachineRoutes(machine);
// Path → RouteNodeconst node = tree.byPath.get("/dashboard"); // RouteNode for "dashboard"
// State ID → RouteNodeconst overview = tree.byStateId.get("overview");console.log(overview?.fullPath); // "/overview"
// Build a RouteMap for framework adaptersconst routeMap = createRouteMap(machine);routeMap.getStateIdByPath("/profile/123"); // "profile"routeMap.getPathByStateId("profile"); // "/profile/:userId"Sending play.route events
import type { PlayRouteEvent } from "@xmachines/play-router";
// Navigate to a state by IDconst event: PlayRouteEvent = { type: "play.route", to: "#dashboard",};actor.send(event);
// Navigate with route parametersactor.send({ type: "play.route", to: "#profile", params: { userId: "123" },});
// Navigate with query parametersactor.send({ type: "play.route", to: "#settings", params: { section: "billing" }, query: { tab: "invoices" },});Implementing a RouterBridgeBase adapter
Extend RouterBridgeBase and implement the three abstract methods for your framework:
import { RouterBridgeBase } from "@xmachines/play-router";import type { RoutableActor } from "@xmachines/play-router";
export class MyRouterBridge extends RouterBridgeBase { private unsubscribe: (() => void) | null = null;
constructor( private readonly myRouter: MyRouter, actor: RoutableActor, routeMap: ReturnType<typeof createRouteMap>, ) { super(actor, routeMap); }
// Tell the framework router to navigate to a path protected navigateRouter(path: string): void { this.myRouter.navigate(path); }
// Subscribe to router location changes, call syncActorFromRouter on each protected watchRouterChanges(): void { this.unsubscribe = this.myRouter.subscribe((location) => { this.syncActorFromRouter(location.pathname, location.search); }); }
// Unsubscribe from router location changes protected unwatchRouterChanges(): void { this.unsubscribe?.(); this.unsubscribe = null; }
// Provide the router's current path for initial deep-link sync protected override getInitialRouterPath(): string { return this.myRouter.state.location.pathname; }}
// Usageconst routeMap = createRouteMap(machine);const bridge = new MyRouterBridge(myRouter, actor, routeMap);bridge.connect();// ...bridge.disconnect();API Summary
Route Extraction
| Export | Description |
|---|---|
extractMachineRoutes(machine) | Convert an XState machine to a RouteTree with bidirectional state ID ↔ path maps |
createRouteMap(machine, options?) | Build a RouteMap directly from a machine (preferred form for adapters) |
createRouteMapFromTree(tree, options?) | Build a RouteMap from an already-extracted RouteTree |
buildRouteTree(routes) | Build a RouteTree from an array of RouteInfo objects |
machineToGraph(machine) | Convert a machine to a typed @statelyai/graph Graph for graph algorithm access |
Route Matching
| Export | Description |
|---|---|
createRouteMatcher(tree) | Create a RouteMatcher that matches URL paths to #stateId values and extracts params |
RouteMap | Bidirectional stateId ↔ path lookup class; supports O(1) exact and O(k) pattern matching |
findRouteById(tree, id) | Look up a RouteNode by state ID |
findRouteByPath(tree, path) | Look up a RouteNode by URL path (supports dynamic patterns) |
Query Utilities
| Export | Description |
|---|---|
getRoutableRoutes(tree) | All routable RouteNodes as a flat array |
getNavigableRoutes(tree, stateId) | Child routes reachable from a state (hierarchical + transition-reachable) |
routeExists(tree, path) | Check whether a path is registered in the tree |
getTransitionReachableRoutes(graph, stateId) | Route paths reachable via XState transitions from a state |
isRouteReachable(graph, fromStateId, toStateId) | Check whether a transition path exists between two states |
Router Bridge
| Export | Description |
|---|---|
RouterBridgeBase | Abstract base class for framework router adapters; implements RouterBridge protocol |
sanitizePathname(path) | Normalize a raw pathname; returns null for paths > 2048 chars or malformed input |
buildPlayRouteEvent(options) | Build a PlayRouteEvent from a pathname + route-map match result |
extractRouteParams(pathname, pattern) | Extract path parameters from a URL using URLPattern |
extractQuery(search) | Extract query parameters from a URL search string |
Validation
| Export | Description |
|---|---|
validateRouteFormat(route, stateId) | Assert route path is non-empty |
validateStateExists(stateId, stateIds) | Assert a state ID is present in the machine graph |
detectDuplicateRoutes(routes) | Throw if any two states share the same URL path |
Key Types
| Export | Description |
|---|---|
RouterBridge | Interface for connect() / disconnect() lifecycle |
RouteTree | Hierarchical tree with root, byStateId, byPath, and optional graph |
RouteNode | Single node in the tree with id, path, fullPath, stateId, children, parent |
RouteInfo | Flat route descriptor extracted from a state node |
PlayRouteEvent | Routing event { type: "play.route", to, params?, query? } |
RoutableActor | Minimal actor interface required by RouterBridgeBase — currentRoute, initialRoute, and send(PlayRouteEvent) |
PlayActor | Full actor interface used by PlayRouterProvider — extends RoutableActor with currentView (Routable + Viewable) |
RouteMapping | { stateId, path } pair used to build a RouteMap |
RouteMapping as BaseRouteMapping | Alias for RouteMapping (backwards-compat re-export) |
MachineGraph | Typed @statelyai/graph Graph with MachineNodeData / MachineEdgeData |
WindowLike | Injectable minimal window interface for SSR / testing |
LocationLike | Injectable minimal location interface for SSR / testing |
Errors (subpath @xmachines/play-router/errors)
| Class | Code | When thrown |
|---|---|---|
RouterSyncError | PLAY_ROUTER_SYNC_FAILED | syncActorFromRouter() fails to send a play.route event |
DuplicateBridgeError | PLAY_ROUTER_DUPLICATE_BRIDGE | A second bridge tries to connect to an actor that already has one |
URLPatternUnavailableError | PLAY_ROUTE_MAP_URLPATTERN_UNAVAILABLE | URLPattern API is absent and no polyfill is loaded |
InvalidRoutePatternError | PLAY_ROUTE_MAP_INVALID_PATTERN | A route pattern string is rejected by the URLPattern constructor |
EmptyRoutePathError | PLAY_ROUTE_EMPTY_PATH | A state declares meta.route: "" |
InvalidStateIdError | PLAY_ROUTE_INVALID_STATE_ID | A route references a state ID not in the machine graph |
DuplicateRoutePathError | PLAY_ROUTE_DUPLICATE_PATH | Two or more states share the same URL path |
UnknownStateTypeError | PLAY_ROUTE_UNKNOWN_STATE_TYPE | A state node has an unrecognised XState .type value |
import { RouterSyncError, DuplicateBridgeError, URLPatternUnavailableError,} from "@xmachines/play-router/errors";
try { bridge.connect();} catch (err) { if (err instanceof DuplicateBridgeError) { // Actor already bridged — call disconnect() first } else if (err instanceof RouterSyncError) { console.error("Router sync failed:", err.message, err.cause); }}Route Configuration
meta.route patterns
Routes are declared on XState state nodes via the meta.route field:
states: { home: { id: "home", meta: { route: "/" }, // static route }, profile: { id: "profile", meta: { route: "/profile/:userId" }, // required parameter }, settings: { id: "settings", meta: { route: "/settings/:section?" }, // optional parameter }, docs: { id: "docs", meta: { route: { path: "/docs", title: "Documentation" } }, // object form },}Relative vs absolute paths
Child routes with a leading / are absolute (do not inherit the parent path). Without a leading /, they resolve relative to their nearest routable ancestor:
states: { dashboard: { id: "dashboard", meta: { route: "/dashboard" }, states: { overview: { id: "overview", meta: { route: "/overview" }, // absolute → fullPath: "/overview" }, stats: { id: "stats", meta: { route: "stats" }, // relative → fullPath: "/dashboard/stats" }, }, },}Always use node.fullPath (never node.path) for browser URL matching and route map construction.
Testing
# Run tests for this packagenpm test -w @xmachines/play-router
# Watch modenpm run test:watch -w @xmachines/play-routerThe package exports a router bridge contract test suite for adapter authors:
import { runBridgeContractTests } from "@xmachines/play-router/test/contract.js";
runBridgeContractTests({ name: "MyRouterBridge", createHarness(initialPath) { // return ContractHarness with bridge, actor, simulateNavigation, getLastNavigatedPath },});Related Packages
- @xmachines/play — Core protocol types (
PlayEvent,PlayError) - @xmachines/play-actor — Abstract actor base class (
AbstractActor,Routable); allAbstractActorsubclasses satisfyRoutableActorstructurally - @xmachines/play-signals — TC39 Signals polyfill used for actor route observation
- @xmachines/play-xstate — XState v5 logic adapter that integrates with route trees
- @xmachines/play-tanstack-react-router — TanStack Router adapter (React)
- @xmachines/play-tanstack-solid-router — TanStack Router adapter (SolidJS)
- @xmachines/play-react-router — React Router v7 adapter
- @xmachines/play-vue-router — Vue Router adapter
- @xmachines/play-solid-router — SolidJS Router adapter
License
MIT — see LICENSE.
Classes
Interfaces
- BuildPlayRouteEventOptions
- LocationLike
- MachineEdgeData
- MachineNodeData
- PlayActor
- PlayRouteEvent
- RoutableActor
- RouteInfo
- RouteMapOptions
- RouteMapping
- RouteMatch
- RouteMatcher
- RouteNode
- RouteObject
- RouterBridge
- RouteTree
- RouteWatcherHandle
- WindowLike
Type Aliases
Functions
- buildPlayRouteEvent
- buildRouteTree
- createRouteMap
- createRouteMapFromTree
- createRouteMatcher
- detectDuplicateRoutes
- extractMachineRoutes
- extractQuery
- extractRouteParams
- findRouteById
- findRouteByPath
- getNavigableRoutes
- getRoutableRoutes
- getTransitionReachableRoutes
- isRouteReachable
- machineToGraph
- routeExists
- sanitizePathname
- validateRouteFormat
- validateStateExists
References
BaseRouteMapping
Renames and re-exports RouteMapping