@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
>= 24.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 | undefinedtoTCleanup,asCleanup(),DISPOSE— the release protocol that every subscription of the ecosystem hands back
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 { assertNonNullable } from "@xmachines/play";import { NonNullableError, PlayError } 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/errors";
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");Cleanup, asCleanup(release), and DISPOSE
Every subscription of the ecosystem hands one value back: the release. Cleanup is that
type. It is a function AND a Disposable, so one value serves both forms — the caller
that keeps the release in a field, and the caller that gives it to a scope.
import { asCleanup, type Cleanup } from "@xmachines/play";
// The explicit form. It stays correct.const stop = watchSignal(count, render);stop();
// The scoped form. The scope releases it, and an exception releases it too.{ using stop = watchSignal(count, render); render(count.get());}asCleanup publishes a release. It gives the SAME function back with the dispose key
added, and wraps nothing, so a caller that compares two releases or holds one in a Set
sees what it saw before.
export function watchSignal<T>(signal: Signal.State<T>, onValue: (v: T) => void): Cleanup { const watcher = new Signal.subtle.Watcher(() => {}); watcher.watch(signal); return asCleanup(() => watcher.unwatch(signal));}DISPOSE is the key that the release sits under, and a class implements the protocol
with it:
import { DISPOSE } from "@xmachines/play";
class Session { close(): void {} [DISPOSE](): void { this.close(); }}Use DISPOSE and not a bare Symbol.dispose. A bundler that downlevels using for a
target below Chrome 134 emits a helper that reads
object[Symbol.dispose || Symbol.for("Symbol.dispose")], and DISPOSE is that same
pair.
The
usingFORM needs"lib": ["ESNext"], and theCleanupVALUE needs nothing. A consumer on a lowerlibreadsCleanupas the plain() => voidthat these functions returned before the protocol existed, and every call site keeps compiling.
API Summary
Exported from @xmachines/play
| Export | Kind | Description |
|---|---|---|
PlayEvent<TPayload> | type | Universal event contract — { type: string } & TPayload |
assertNonNullable | function | Asserts that the value is not null. It returns the narrowed value |
Cleanup | type | The release of a subscription — (() => void) & Disposable |
asCleanup | function | Publishes one release as a Cleanup. It changes the function itself |
DISPOSE | const | The symbol key that a release sits under |
DisposeKey | type | The type of that key, and never where the consumer omits the lib |
Exported from @xmachines/play/errors
| Export | Kind | Description |
|---|---|---|
PlayError | class | The base class of every @xmachines/* typed error |
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-url | @xmachines/play-url/errors |
@xmachines/play-view | @xmachines/play-view/errors |
@xmachines/play-xstate | @xmachines/play-xstate/errors |
@xmachines/play-vue-router | @xmachines/play-vue-router/errors |
License
MIT © Mikael Karon
See LICENSE for details.