@xmachines/play
API / @xmachines/play
Core protocol layer for the Universal Player Architecture. It defines
PlayEvent,PlayError, and the contracts that keep the business logic loosely coupled to the runtime adapters.
Installation
pnpm add @xmachines/playThis package requires Node.js
>= 22.0.0. Every package is an ES module ("type": "module").
Overview
@xmachines/play is the base package of the XMachines ecosystem. It defines the smallest set of types and utilities that every other @xmachines/* package builds on:
PlayEvent<TPayload>— the universal event contract for Actor ↔ Infrastructure communicationPlayError— the typed base class for all@xmachines/*runtime errorsNonNullableError— the error that a package throws when a required value isnullorundefinedassertNonNullable()— the assertion utility that narrowsT | null | undefinedtoT
These protocols implement the architectural invariants that the Play RFC defines:
| # | Invariant | Description |
|---|---|---|
| INV-01 | Actor Authority | The Actor is the final authority. Guards decide all transitions |
| INV-02 | Strict Separation | The business logic never imports a UI framework or a routing library |
| INV-04 | Passive Infrastructure | The infrastructure observes the Actor signals. It never enforces a guard |
| INV-05 | Signal-Only Reactivity | TC39 Signals are the only medium that crosses a boundary |
Usage
PlayEvent<TPayload>
The minimal event contract is any object with a type: string property. The contract is framework-agnostic. It works with XState, with Robot, and with every other state machine library.
import type { PlayEvent } from "@xmachines/play";
// Flexible (accepts any additional fields):const event: PlayEvent = { type: "auth.login", userId: "user123" };
// Type-safe (with generic payload):type LoginEvent = PlayEvent<{ userId: string; timestamp: number }>;
const loginEvent: LoginEvent = { type: "auth.login", userId: "user123", timestamp: Date.now(),};
// TypeScript error: missing required fieldconst invalid: LoginEvent = { type: "auth.login" }; // Error!PlayError
Base class for every @xmachines/* runtime error. Each error has a stable scope (the class or the module that throws it) and a stable code (a machine-readable identifier). Always branch on .code or on the subclass. Never branch on .message.
import { PlayError, assertNonNullable } from "@xmachines/play";import { NonNullableError } from "@xmachines/play/errors";
try { assertNonNullable(document.getElementById("app"), "#app");} catch (err) { if (err instanceof NonNullableError) { // err.scope === "assertNonNullable" // err.code === "PLAY_NON_NULLABLE" console.error(`Missing value: ${err.message}`); } else if (err instanceof PlayError) { // Any other @xmachines/* error console.error(`[${err.scope}:${err.code}] ${err.message}`); } else { throw err; }}Extend PlayError in your own @xmachines/*-compatible packages:
import { PlayError } from "@xmachines/play";
export class MyPackageError extends PlayError { constructor(message: string, options?: ErrorOptions) { super("MyScope", "MY_PACKAGE_ERROR_CODE", message, options); this.name = "MyPackageError"; }}assertNonNullable(value, name?)
The assertion utility returns value with the type NonNullable<V>, or it throws NonNullableError. It removes the unsafe ! non-null assertion.
import { assertNonNullable } from "@xmachines/play";
// DOM element lookup — no intermediate variable or `!` needed:const el = assertNonNullable(document.getElementById("app"), "#app");API Summary
Exported from @xmachines/play
| Export | Kind | Description |
|---|---|---|
PlayEvent<TPayload> | type | Universal event contract — { type: string } & TPayload |
PlayError | class | Base class for all @xmachines/* typed errors |
NonNullableError | class | assertNonNullable throws it when a value is null or undefined |
assertNonNullable | function | Asserts that the value is not null. It returns the narrowed value |
Exported from @xmachines/play/errors
| Export | Kind | Description |
|---|---|---|
PlayError | class | The base error class, re-exported |
NonNullableError | class | scope: "assertNonNullable", code: "PLAY_NON_NULLABLE" |
Error Codes
| Code | Class | Thrown When |
|---|---|---|
PLAY_NON_NULLABLE | NonNullableError | assertNonNullable() receives null or undefined |
Every other @xmachines/* package exports its own error subclasses from its ./errors subpath:
| Package | Import path |
|---|---|
@xmachines/play | @xmachines/play/errors |
@xmachines/play-router | @xmachines/play-router/errors |
@xmachines/play-xstate | @xmachines/play-xstate/errors |
@xmachines/play-vue-router | @xmachines/play-vue-router/errors |
Testing
Run tests for this package in isolation:
pnpm --filter @xmachines/play testOr from the package directory:
pnpm testTests use Vitest and cover the PlayError class construction, inheritance, cause support, and subclassing patterns.
License
MIT © Mikael Karon
See LICENSE for details.
@xmachines/play - the core protocol layer
This package defines the architectural contracts that carry the communication between the Actor and the infrastructure, with no direct dependency between them. RFC section 5.2 gives these protocols. They are the base of the loose coupling between the business logic and the runtime adapters.
The exports
PlayEvent
- It is every object with a
type: stringproperty - The generic
TPayloadparameter gives a type-safe event shape, and it is optional - The default is
Record<string, unknown>, which accepts each shape - It is framework-agnostic, and it is not bound to XState or to another library
Use:
// Flexible, the default:const event: PlayEvent = { type: "auth.login", userId: "123" };
// Type-safe, with the generic parameter:type LoginEvent = PlayEvent<{ userId: string }>;const event: LoginEvent = { type: "auth.login", userId: "123" };The common event patterns:
- A domain event:
{ type: 'auth.login', userId: '123' } - Your own event:
{ type: 'form.submit', data: {...} }
The routing events come from @xmachines/play-router:
- PlayRouteEvent: the routing event with the parameters and the target state ID
- RouterBridge: the protocol that connects a router adapter to an actor
The browser navigation: a router adapter handles the browser BACK and FORWARD
buttons, through the popstate event. The user presses BACK or FORWARD, the
router detects the new URL, and it sends a PlayRouteEvent to the actor. The actor
then checks the event.
import type { PlayRouteEvent, RouterBridge } from "@xmachines/play-router";The architectural invariants
These protocols enforce the invariants below:
- Actor Authority: the infrastructure makes a request, and the Actor decides the validity
- Strict Separation: no layer depends on another layer directly
- Passive Infrastructure: the infrastructure observes the Actor signals. It never controls them
- Signal-Only Reactivity: every state change goes through a TC39 Signal
- State-Driven Reset: each navigation follows the transition rules of the state machine