@edium/fsm
v3.0.1
Published
Simple finite state machine framework that can be used with client or server.
Maintainers
Readme
Edium Finite State Machine
Overview
Edium FSM is a lightweight, flexible finite state machine written in TypeScript. It works in both the browser and Node.js, supports local and global transitions, entry/exit/decide actions, blocked transitions, and optional context objects passed to all state actions.
Version 3.x introduces a fully asynchronous state machine, useful for client/server apps, workflow systems, async game logic, and anything requiring await inside state actions.
IMPORTANT Version 3.x is 100% backward compatible with earlier versions. The original synchronous API is unchanged — the new async API is optional.
Features
- Unlimited number of states.
- One or more completed states.
- Optional “go to previous state” behavior.
- Throws errors on invalid transitions.
- Entry, exit, and decide actions.
- Exit actions can block transitions.
- Reset and optional restart behavior.
- Local transitions (state-specific).
- Global transitions (bypass state rules).
- Trigger-driven state changes.
- Optional context object passed to all actions.
- Async FSM option for
await-driven logic.
Migration to 3.x
Version 3.x adds asynchronous FSM classes (AsyncState, AsyncStateMachine, AsyncTransition) without changing the existing synchronous API.
If you used version 2.x (or earlier)
You don’t need to change anything:
import { State, StateMachine } from '@edium/fsm';
const machine = new StateMachine('My FSM');
machine.start(...);
machine.trigger('next');This continues to work exactly as before.
To use the new async FSM
Switch to:
import { AsyncState, AsyncStateMachine } from '@edium/fsm';
await asyncMachine.start(...);
await asyncMachine.trigger('next');Both styles can coexist in the same project.
Installation
pnpm install @edium/fsmTests & Coverage
The codebase is fully unit-tested with near-100% coverage. All code is linted, prettified and type-checked.
Synchronous Machine
Importing
import { State, StateMachine } from '@edium/fsm';const { State, StateMachine } = require('@edium/fsm');Example
const entryAction = (state, context) => {
state.trigger('next');
};
const exitAction = (state, context) => {
return true;
};
const decideAction = (state, context) => {
const index = context.randomize();
if (index === 0) {
state.trigger('gotoThree');
} else if (index === 1) {
state.trigger('gotoFour');
}
};
const finalAction = (state) => {};
const context = {
randomize: () => Math.floor(Math.random() * 2)
};
const stateMachine = new StateMachine('My first state machine', context);
const s1 = stateMachine.createState('My first state', false, entryAction);
const s2 = stateMachine.createState('My second state', false, decideAction, exitAction);
const s3 = stateMachine.createState('My third state', false, entryAction);
const s4 = stateMachine.createState('My fourth state', false, entryAction);
const s5 = stateMachine.createState('My fifth and final state', true, finalAction);
s1.addTransition('next', s2);
s2.addTransition('gotoThree', s3);
s2.addTransition('gotoFour', s4);
s3.addTransition('next', s5);
s4.addTransition('next', s5);
stateMachine.start(s1);Asynchronous Machine
This example implements a safe asynchronous state machine that does not allow the user to directly change the state during running work.
IMPORTANT Do not call asyncStateMachine.trigger() from inside entry/exit actions. Instead you must use state.triggerInternal(). Internal triggers are queued and processed safely once the current transition finishes, whereas external triggers will generate an error.
Importing
import { AsyncState, AsyncStateMachine } from '@edium/fsm';const { AsyncState, AsyncStateMachine } = require('@edium/fsm');Example
const entryAction = async (state, context) => {
// Safe: internal triggers are queued if the machine is already busy.
await state.triggerInternal('next');
};
const exitAction = async (state, context) => {
return true;
};
const decideAction = async (state, context) => {
const index = context.randomize();
if (index === 0) {
await state.triggerInternal('gotoThree');
} else if (index === 1) {
await state.triggerInternal('gotoFour');
}
};
const finalAction = async (state) => {};
const context = {
randomize: () => Math.floor(Math.random() * 2)
};
const asyncStateMachine = new AsyncStateMachine('My first async state machine', context);
const s1 = asyncStateMachine.createState('My first state', false, entryAction);
const s2 = asyncStateMachine.createState('My second state', false, decideAction, exitAction);
const s3 = asyncStateMachine.createState('My third state', false, entryAction);
const s4 = asyncStateMachine.createState('My fourth state', false, entryAction);
const s5 = asyncStateMachine.createState('My fifth and final state', true, finalAction);
s1.addTransition('next', s2);
s2.addTransition('gotoThree', s3);
s2.addTransition('gotoFour', s4);
s3.addTransition('next', s5);
s4.addTransition('next', s5);
await asyncStateMachine.start(s1);Common Patterns
1. State Guards (Blocking Transitions)
const guardedExit = (state, ctx) => {
return ctx.isAllowed === true;
};2. Global Transitions
machine.addGlobalTransition('reset', startState);
machine.trigger('reset');3. Sub-State Machines (Hierarchical Composition)
const sub = new StateMachine('sub');
const parent = new StateMachine('parent');
parent.createState('run-sub', false, () => {
sub.start();
});4. Async Workflows
const entry = async (s, ctx) => {
const data = await fetch('/api/data').then((r) => r.json());
ctx.data = data;
// Safe: internal triggers are queued if the machine is already busy.
await s.triggerInternal('next');
};5. Error Handling
All internal errors are thrown as StateMachineError:
error.message– Prefixed message:State Machine (My Machine) - …error.machine– State machine nameerror.state– Current state name (if any)error.trigger– Trigger id (if any)
You can instanceof StateMachineError to distinguish FSM errors from other errors.
Created by Edium Interactive LLC.
