npm.io
6.3.0 • Published 2 months ago

machina

Licence
MIT
Version
6.3.0
Deps
0
Size
278 kB
Vulns
0
Weekly
0
Stars
2.0K

machina

Focused finite state machines for JavaScript and TypeScript. States in, states out.

Full documentation

https://machina-js.org

Install

npm install machina

Quick start — createFsm

createFsm is the standard choice: one config, one FSM instance, one internal context object. Handler signatures receive a { ctx, inputName, defer, emit } args object — no this, works with arrow functions.

Handlers return a state name to transition, or nothing to stay put.

import { createFsm } from "machina";

const light = createFsm({
    id: "traffic-light",
    initialState: "green",
    context: { tickCount: 0 },
    states: {
        green: {
            _onEnter({ ctx }) {
                ctx.tickCount = 0;
            },
            tick({ ctx }) {
                ctx.tickCount++;
            },
            timeout({ ctx }) {
                if (ctx.tickCount >= 5) return "yellow";
            },
        },
        yellow: {
            timeout: "red", // string shorthand — always transitions
        },
        red: {
            timeout: "green",
        },
    },
});

light.handle("tick");
light.handle("tick");
light.handle("timeout"); // stays green — tickCount is 2, not >= 5

light.currentState(); // "green"
light.compositeState(); // "green" (dot-delimited path, useful with child FSMs)
light.canHandle("timeout"); // true

light.reset(); // back to initialState, fires _onEnter
light.dispose(); // tears down; all subsequent calls are silent no-ops
Public API — Fsm
Method Description
handle(inputName, ...args) Dispatch an input to the current state's handler
canHandle(inputName) True if the current state has a handler (or "*") for this input
transition(toState) Directly transition; fires _onExit, _onEnter, lifecycle events
reset() Transition back to initialState
currentState() Returns the current state name
compositeState() Dot-delimited path including active child FSM states
on(eventName, callback) Subscribe to a lifecycle event (returns { off() })
emit(eventName, data?) Emit a custom event
dispose(options?) Permanently shut down; cascades to child FSMs by default

createBehavioralFsm

createBehavioralFsm defines behavior once and applies it to any number of independent client objects. Per-client state lives in a WeakMap — no properties are stamped onto the client. The client object IS the context; handlers receive it as ctx.

The client type is provided as an explicit type parameter since it can't be inferred from the config.

import { createBehavioralFsm } from "machina";

interface Connection {
    url: string;
    retries: number;
}

const connFsm = createBehavioralFsm<Connection>({
    id: "connectivity",
    initialState: "disconnected",
    states: {
        disconnected: {
            connect: "connecting",
        },
        connecting: {
            connected: "online",
            failed({ ctx }) {
                ctx.retries++;
                if (ctx.retries >= 3) return "error";
                return "disconnected";
            },
        },
        online: {
            disconnect: "disconnected",
        },
        error: {
            reset({ ctx }) {
                ctx.retries = 0;
                return "disconnected";
            },
        },
    },
});

const connA = { url: "wss://host-a.example.com", retries: 0 };
const connB = { url: "wss://host-b.example.com", retries: 0 };

connFsm.handle(connA, "connect");
connFsm.handle(connB, "connect");
connFsm.handle(connB, "failed");

connFsm.currentState(connA); // "connecting"
connFsm.currentState(connB); // "disconnected"

BehavioralFsm has the same API as Fsm, except every method takes the client object as its first argument. It also adds rehydrate(client, compositeState) for restoring previously-serialized clients without triggering lifecycle hooks or events.

Rehydrating persisted clients

rehydrate() places a client at a known state with no lifecycle activity — no _onEnter, no events. It accepts the same dot-path format that compositeState() produces, enabling cold-resume workflows where clients are serialized to external storage and later restored.

// Persist
const snapshot = { client: connA, state: connFsm.compositeState(connA) };

// Restore (possibly a different process)
const { client, state } = JSON.parse(stored);
connFsm.rehydrate(client, state); // silent — no _onEnter, no events
connFsm.handle(client, "retry"); // proceeds from restored state

For hierarchical FSMs, pass the dot-delimited path: fsm.rehydrate(client, "active.uploading.retrying"). Throws on invalid states or structural mismatches.

Hierarchical states

Attach a child FSM to any state via _child. Inputs are dispatched to the child first; unhandled inputs bubble up to the parent.

import { createFsm } from "machina";

const childFsm = createFsm({
    id: "upload-phases",
    initialState: "preparing",
    context: {},
    states: {
        preparing: { ready: "uploading" },
        uploading: { done: "verifying" },
        verifying: { verified: "complete" },
        complete: {},
    },
});

const uploader = createFsm({
    id: "uploader",
    initialState: "idle",
    context: {},
    states: {
        idle: {
            start: "active",
        },
        active: {
            _child: childFsm, // delegates inputs here first
            cancel: "idle", // "cancel" is not on childFsm, so it bubbles up here
        },
    },
});

uploader.handle("start");
uploader.compositeState(); // "active.preparing"

uploader.handle("ready");
uploader.compositeState(); // "active.uploading"

uploader.handle("cancel");
uploader.compositeState(); // "idle"

The parent re-enters initialState of the child FSM whenever the parent transitions into the state that owns _child.

Deferred input

Call defer() inside a handler to queue the current input for replay after the next transition. Pass { until: "stateName" } to replay only on entry to a specific state.

const fsm = createFsm({
    id: "loader",
    initialState: "loading",
    context: {},
    states: {
        loading: {
            _onEnter() {
                // simulate async load completing
                setTimeout(() => fsm.handle("loaded"), 100);
            },
            // Can't process "save" while still loading — replay it later
            save({ defer }) {
                defer({ until: "ready" });
            },
            loaded: "ready",
        },
        ready: {
            save() {
                // deferred "save" replays here automatically after transitioning in
                console.log("saving");
            },
        },
    },
});

fsm.handle("save"); // deferred — not yet in "ready"
// after "loaded" fires and transitions to "ready", "save" replays automatically

Events

Both Fsm and BehavioralFsm emit lifecycle events you can subscribe to with on(). Use "*" to catch everything.

const sub = light.on("transitioned", ({ fromState, toState }) => {
    console.log(`${fromState} -> ${toState}`);
});

sub.off(); // unsubscribe

// wildcard — receives every event
light.on("*", (eventName, data) => {
    console.log(eventName, data);
});
Built-in events
Event Payload Fired when
transitioning { fromState, toState } A transition is about to occur
transitioned { fromState, toState } A transition completed
handling { inputName } An input is about to be dispatched
handled { inputName } An input was successfully handled
nohandler { inputName, args } No handler found in current state
invalidstate { stateName } Transition targeted a nonexistent state
deferred { inputName } An input was deferred

BehavioralFsm events include a client field in every payload to identify which client the event pertains to.

TypeScript

machina is written in TypeScript. State names, input names, and handler signatures are all inferred from the config object — no manual type parameters needed for createFsm.

import type {
    FsmConfig,
    FsmEventMap,
    BehavioralFsmEventMap,
    HandlerArgs,
    HandlerFn,
    StateNamesOf,
    InputNamesOf,
    Subscription,
    DisposeOptions,
} from "machina";

// StateNamesOf and InputNamesOf extract literal unions from your states config:
// type MyStates = StateNamesOf<typeof myConfig.states>; // "green" | "yellow" | "red"
// type MyInputs = InputNamesOf<typeof myConfig.states>; // "tick" | "timeout"

Transition targets in string shorthand are validated against actual state keys at compile time. A typo like timeout: "yellw" is a type error.

Disposal

dispose() permanently shuts down an FSM. All subsequent method calls are silent no-ops. Child FSMs are disposed by default.

fsm.dispose(); // also disposes child FSMs

fsm.dispose({ preserveChildren: true }); // leave child FSMs running

Companion tools

  • machina-inspect — static analysis for FSM configs: unreachable states, _onEnter loops, missing handlers.
  • machina-test — Jest/Vitest custom matchers for testing FSM graph topology. Assert reachability and catch dead states from your test suite.
  • eslint-plugin-machina — ESLint plugin wrapping machina-inspect for inline editor feedback.

License

MIT + GPL-2.0