@xmachines/play-router
Documentation / @xmachines/play-router
Route tree extraction from XState v5 state machines with routing patterns
BFS graph crawling and bidirectional route lookup enabling Actor Authority over navigation.
Overview
@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.
It also exports RouterBridgeBase, the shared base class used by framework adapters to implement RouterBridge with consistent actor↔router synchronization behavior.
RouterBridgeBase is the policy point; framework adapters are thin ports that implement only framework-specific navigate/subscribe/unsubscribe behavior.
Per RFC Play v1, this package implements:
- Actor Authority (INV-01): Routes derive from machine definitions, not external configuration
Routing: Supports meta.route detection, play.route event routing, and pattern matching for dynamic parameters.
Installation
npm install xstate@^5.0.0npm install @xmachines/play-routerPeer dependencies:
xstate^5.0.0 — 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 — it is the consumer’s responsibility to load it when needed.
Quick Start
import { createMachine } from "xstate";import { extractMachineRoutes } from "@xmachines/play-router";
// Route pattern (recommended)const machine = createMachine({ id: "app", initial: "home", states: { home: { id: "home", meta: { route: "/", view: { component: "Home" } }, }, dashboard: { id: "dashboard", meta: { route: "/dashboard", view: { component: "Dashboard" } }, initial: "overview", states: { overview: { id: "overview", meta: { route: "/overview", view: { component: "Overview" } }, }, settings: { id: "settings", meta: { route: "/settings/:section?", view: { component: "Settings" } }, // Optional parameter }, }, }, },});
const tree = extractMachineRoutes(machine);
// Bidirectional lookupconsole.log(tree.byPath.get("/dashboard/overview")); // RouteNodeconsole.log(tree.byId.get("overview")); // RouteNode
// Pattern matching for dynamic routesconst settingsRoute = tree.byPath.get("/settings/profile");console.log(settingsRoute?.id); // "settings"Vanilla Browser Example
See examples/vanilla-demo/ for a complete example using vanilla TypeScript with the browser History API.
The demo demonstrates:
- RouteTree extraction from XState machine meta.route
- History API integration (pushState, popstate)
- Bidirectional synchronization (actor ↔ URL)
- Protected route guards (authentication redirects)
- Dynamic route parameters (/profile/:userId)
Run the demo:
cd packages/play-router/examples/demonpm installnpm run devOpen http://localhost:5174/ and explore:
- Login with any username
- Navigate between home and profile
- Use browser back/forward buttons
- Try accessing protected routes directly
Key implementation patterns:
// Extract routes from machineconst routeTree = extractMachineRoutes(authMachine);const routeMap = createRouteMap(routeTree);
// Actor → URL syncconst watcher = new Signal.subtle.Watcher(() => { queueMicrotask(() => { watcher.getPending(); const route = actor.currentRoute.get(); if (route) { window.history.pushState({}, "", route); } watcher.watch(actor.currentRoute); });});
// URL → Actor sync (with pattern matching)window.addEventListener("popstate", () => { const path = window.location.pathname; const { to, params } = routeMap.resolve(path); if (to) { actor.send({ type: "play.route", to, params }); }});
// Initial URL handlingconst initialPath = window.location.pathname;if (initialPath !== "/") { const { to, params } = routeMap.resolve(initialPath); if (to) { actor.send({ type: "play.route", to, params }); }}This example shows the core routing concepts without framework dependencies, making it ideal for understanding how @xmachines/play-router integrates with browser History API.
Canonical Watcher Lifecycle
Bridge implementations should use one watcher flow:
notifyqueueMicrotaskgetPending()- read actor route and sync infrastructure state
- re-arm with
watch(...)orwatch()
Watcher notification is one-shot; re-arm is required.
Bridge Cleanup Contract
Bridge teardown must be explicit and deterministic:
disconnect/disposemust unwatch signal subscriptions and unhook router listeners.- Do not rely on GC-only cleanup guidance.
- Infrastructure remains passive: bridges observe and forward intents, actors decide validity.
API Reference
extractMachineRoutes()
Main entry point — crawls state machine, extracts routes, builds tree:
const tree = extractMachineRoutes(machine: AnyStateMachine): RouteTree;Detection:
- States with
meta.routein meta object
Returns: RouteTree with:
routes: RouteNode[]- Array of route nodesbyPath: Map<string, RouteNode>- URL path → route nodebyId: Map<string, RouteNode>- State ID → route node
Throws: Error if routes are invalid (malformed paths, missing state IDs, duplicates)
Example:
import { extractMachineRoutes } from "@xmachines/play-router";
const tree = extractMachineRoutes(authMachine);
// Query routesconst loginRoute = tree.byId.get("login");console.log(loginRoute?.path); // "/login"
const dashboardRoute = tree.byPath.get("/dashboard");console.log(dashboardRoute?.id); // "dashboard"crawlMachine()
Low-level BFS traversal of state machine graph:
const visits = crawlMachine(machine: AnyStateMachine): StateVisit[];Returns: Array of state visits in breadth-first order with:
path: string[]- State path (e.g., [“dashboard”, “settings”])parent: StateNode | null- Parent state nodenode: StateNode- Current state node
Example:
import { crawlMachine } from "@xmachines/play-router";
const visits = crawlMachine(machine);visits.forEach((visit) => { console.log("State:", visit.path.join(".")); console.log("Parent:", visit.parent?.id ?? "root");});Query Utilities
// Get child routes from stateconst children = getNavigableRoutes(tree, "dashboard");
// Check if route existsconst exists = routeExists(tree, "/profile/:userId");Examples
Route Detection
import { extractMachineRoutes } from "@xmachines/play-router";import { createMachine } from "xstate";
const machine = createMachine({ initial: "home", states: { home: { id: "home", meta: { route: "/", view: { component: "Home" } }, }, profile: { id: "profile", meta: { route: "/profile/:userId", view: { component: "Profile" } }, // Parameter pattern }, settings: { id: "settings", meta: { route: "/settings/:section?", view: { component: "Settings" } }, // Optional parameter }, },});
const tree = extractMachineRoutes(machine);
// Bidirectional mappingconst profileById = tree.byId.get("profile");console.log(profileById?.path); // "/profile/:userId"
const profileByPath = tree.byPath.get("/profile/user123");console.log(profileByPath?.id); // "profile"Hierarchical Route Tree
import { extractMachineRoutes, getNavigableRoutes } from "@xmachines/play-router";
const machine = createMachine({ initial: "app", states: { app: { id: "app", meta: { route: "/", view: { component: "AppShell" } }, initial: "dashboard", states: { dashboard: { id: "dashboard", meta: { route: "/dashboard", view: { component: "Dashboard" } }, initial: "overview", states: { overview: { id: "overview", meta: { route: "/overview", view: { component: "Overview" } }, }, analytics: { id: "analytics", meta: { route: "/analytics", view: { component: "Analytics" } }, }, }, }, }, }, },});
const tree = extractMachineRoutes(machine);
// Get child routesconst dashboardChildren = getNavigableRoutes(tree, "dashboard");console.log(dashboardChildren.map((r) => r.id)); // ["overview", "analytics"]
// Route inheritanceconst analyticsRoute = tree.byId.get("analytics");console.log(analyticsRoute?.path); // "/dashboard/analytics" (inherited parent path)Pattern Matching
import { extractMachineRoutes } from "@xmachines/play-router";
const machine = createMachine({ states: { user: { id: "user", meta: { route: "/user/:userId", view: { component: "User" } }, // Required parameter }, settings: { id: "settings", meta: { route: "/settings/:section?", view: { component: "Settings" } }, // Optional parameter }, },});
const tree = extractMachineRoutes(machine);
// Pattern matching for actual URLsconst userRoute = tree.byPath.get("/user/user123");console.log(userRoute?.id); // "user"
const settingsDefault = tree.byPath.get("/settings");console.log(settingsDefault?.id); // "settings" (optional param)
const settingsProfile = tree.byPath.get("/settings/profile");console.log(settingsProfile?.id); // "settings" (with param)Route Configuration
Route Pattern (Recommended)
states: { dashboard: { id: "dashboard", // Required for bidirectional lookup meta: { route: "/dashboard", // URL path - marks state as routable }, },}Alternative Pattern
states: { dashboard: { id: "dashboard", meta: { route: "/dashboard", }, },}Route Inheritance
states: { parent: { id: "parent", meta: { route: "/parent", view: { component: "Parent" } }, states: { absolute: { id: "absolute", meta: { route: "/absolute", view: { component: "Absolute" } }, // Starts with / → doesn't inherit }, relative: { id: "relative", meta: { route: "relative", view: { component: "Relative" } }, // No leading / → inherits parent // Final path: "/parent/relative" }, }, },}Dynamic Parameters
meta: { route: "/profile/:userId", // Required parameter route: "/settings/:section?", // Optional parameter route: "/docs/:category/:page", // Multiple parameters view: { component: "AnyView" },}Parameter substitution: Values extracted from context or event params (handled by play-xstate adapter).
Architecture
This package enables Actor Authority (INV-01):
- Routes derive from machine: Business logic defines routes in state machine, not external config
- BFS traversal: Systematic state discovery ensures all nested states visited
- Bidirectional mapping: Fast lookup by path (browser URL) or by ID (state machine)
- Build-time validation: Invalid routes throw errors during extraction, not runtime
Enhancements:
meta.routedetection via state metadata- Pattern matching for dynamic routes (
:paramand:param?) - State ID ↔ path bidirectional maps for
play.routeevents
Related Packages
- @xmachines/play-xstate - XState adapter using route extraction
- @xmachines/play-tanstack-react-router - TanStack Router adapter using route trees
- @xmachines/play-react-router - React Router v7 adapter using RouterBridgeBase
- @xmachines/play - Protocol types
License
Copyright (c) 2016 Mikael Karon. All rights reserved.
This work is licensed under the terms of the MIT license.
For a copy, see https://opensource.org/licenses/MIT.
Classes
Interfaces
- BaseRouteMapping
- BrowserHistory
- BrowserWindow
- ConnectRouterOptions
- PlayRouteEvent
- RouteInfo
- RouteMap
- RouteNode
- RouteObject
- RouterBridge
- RouteTree
- StateVisit
- VanillaRouter