aifsmjs
v0.5.0
Published
Small, strict FSM library for deterministic, replayable state machines in any TypeScript/JS app — multi-step forms, checkout funnels, auth flows, tutorials, scene flow. Pure step() lifecycle, opt-in effects, inspect, replay, and a fast-check property-base
Maintainers
Readme
aifsmjs
A small, strict FSM library for any TypeScript/JS app that needs deterministic, replayable state transitions. Lifecycle is a pure
step()function. Chain-of-Responsibility intuition is reserved for cross-cutting concerns (observe / persist / replay), never for the transition core.
Part of the ai*js micro-runtime ecosystem — see also aibridgejs (cross-context RPC) and aiecsjs (ECS).
Primary audience: developers building stateful flows — multi-step forms, checkout funnels, auth flows, tutorial sequences, document-status workflows, scene flow in interactive apps, and the same patterns in browser-based games (PixiJS / Svelte 5 / plain Canvas / WebGL). The core is environment-neutral (pure function + adapter boundary): browser, Node, Bun, Deno, Flutter WebView, and Web Workers all work. The Roadmap section keeps gaming-specific niceties (tick hook, ECS bridge) as opt-in subpaths, not core surface.
Why aifsmjs
Developers coming from C# Chain-of-Responsibility instinctively wrap FSM lifecycle in a cancellable middleware chain. In FSM territory that breaks determinism and replay. Web games in particular need replayable, serializable, worker-friendly state, so aifsmjs goes the other way:
- Lifecycle is a pure function:
step(def, snapshot, event, impl)runsguards → exit → action → entryin a fixed, uninterruptible order. - CoR intuition is reserved for cross-cutting layers:
inspect/provides a Koa-style middleware pipeline, but it can only observe — never alter the transition outcome. - Definition is plain data: guards / actions / effects are referenced by string; implementations are injected only at runtime. Serializable, transferable across Web Workers, persistable to a database.
- PBT is first-class: built-in
fast-checkfc.commandsadapter plus 6 generic property tests. No comparable library currently ships this.
In ecosystem terms: closer to Robot3's functional composition + XState v5's and/or/not guard combinators + @xstate/store v3's enq.effect() dual-track side effects. The core measures ~2.8KB ESM gzipped (v0.1.0); every opt-in subpath is independently tree-shakeable.
Quick Start
pnpm add aifsmjsimport { setup, createRuntime, assign } from "aifsmjs";
type Ctx = { ticks: number };
type Evt = { type: "NEXT" };
// 1. Definition is plain data; setup<Ctx, Evt>() lets States be inferred from
// the keys of `states`, so you don't have to repeat them.
const trafficLight = setup<Ctx, Evt>().defineMachine({
id: "trafficLight",
initial: "red",
context: { ticks: 0 },
states: {
red: { on: { NEXT: { target: "green", actions: ["bump"] } } },
green: { on: { NEXT: { target: "yellow", actions: ["bump"] } } },
yellow: { on: { NEXT: { target: "red", actions: ["bump"] } } },
},
});
// 2. Implementations are injected only at runtime
const runtime = createRuntime(trafficLight, {
actions: {
bump: assign(({ context }) => ({ ticks: context.ticks + 1 })),
},
});
// 3. Interact
runtime.send({ type: "NEXT" });
console.log(runtime.getSnapshot().value); // "green"
console.log(runtime.getSnapshot().context); // { ticks: 1 }The bare
defineMachine<Ctx, Evt, States>({...})form is still available as an escape hatch when you need explicit control over union event types. In normal cases prefersetup().defineMachine().
Mental Model
┌──────────────────────┐ ┌──────────────────────┐
│ MachineDefinition │ │ Implementations │
│ (plain data, JSON) │ + │ (guards/actions/ │
│ • states │ │ effects fn map) │
│ • on / target │ │ │
│ • string refs │ │ │
└──────────┬───────────┘ └──────────┬───────────┘
│ │
└──────────────┬───────────────┘
▼
┌────────────────────────┐
│ step(def, snap, evt, │ ← pure function
│ impl) │ fixed order, uninterruptible
└───────────┬────────────┘
▼
┌────────────────────────┐
│ { snapshot, │
│ effects: [...] } │ caller decides when
└───────────┬────────────┘ to dispatch effects
▼
┌────────────────────────┐
│ createRuntime(...) │ ← thin wrapper
│ state holder + send │
└────────────────────────┘The three layers are fully decoupled: take step() alone for replay, take MachineDefinition alone for visualization, and createRuntime is just the convenience layer that glues them.
Capabilities / Limitations
| Will do (v1) | Won't do |
| --------------------------------------------------- | ------------------------------------------------- |
| Flat states + transitions | Parallel state regions |
| Hierarchical sugar via state.sub (stable since 0.4.0) | Closures embedded in definition (breaks serialize) |
| Guards (sync only; inline async throws InvalidDefinitionError at defineMachine; runtime throws AsyncGuardError on thenable return) | Async guards |
| Actions (assign + enqueue effects) | Async API inside an action (use an effect) |
| Fire-and-forget effects | Actor invocation / spawn |
| Read-only inspect middleware | Cancellable transition middleware |
| replay(initial, log, def, impl) pure function | Time-travel debugger (v2 candidate) |
| fast-check fc.commands adapter | Custom PBT framework |
| String ref + runtime injection | Single root import for everything |
| Tree-shake friendly subpath exports | ECS / Pixi bridges (opt-in subpath, not core) |
Design Philosophy
UML statecharts and SCXML both mandate exit → transition action → entry as an atomic sequence. The moment a middleware handler can call next() or throw to abort, you can land in an invalid state — "entered the new state but never exited the old one" — which destroys:
- Determinism: the same event sequence no longer guarantees the same snapshot.
- Replay: event logs cannot reproduce the same outcome in another environment.
- PBT shrinking: fast-check's counter-example minimization presumes a deterministic machine.
XState v5 removed the predictableActionArguments flag (actions are now always predictable) precisely because of this lesson from v4. Spring StateMachine flags its cancellable Interceptor as a "relatively deep internal feature" for the same reason.
So aifsmjs splits the CoR chain instinct two ways:
| Use case | How it is handled |
| ------------------------------ | ------------------------------------------------------- |
| Chained guard predicates | and/or/not higher-order combinators |
| Multi-step action sequencing | actions: [...] array, runs in order to completion |
| Cross-cutting (log/persist) | inspect/ middleware — read-only, no cancel ability |
The moment definitions contain closures, you lose:
JSON.stringifyround-trip for DB / localStorage persistencepostMessagetransfer to a Web Worker- Static reachability analysis by a visualizer tool
- Auto-generated event arbitraries from a PBT adapter
aifsmjs follows the XState v5 two-phase pattern (setup().createMachine()): the definition uses string refs; the function map is injected at createRuntime(). Inline functions are still allowed but flagged as an escape hatch.
Core API
defineMachine<C, E, S>(def)
function defineMachine<
Ctx,
Evt extends { type: string },
States extends string,
>(def: MachineDef<Ctx, Evt, States>): MachineDef<Ctx, Evt, States>;Pure data builder. Freezes the whole definition and validates that initial exists in the states map.
createRuntime(def, impl, opts?)
function createRuntime<C, E, S>(
def: MachineDef<C, E, S>,
impl: Implementations<C, E>,
opts?: { middleware?: readonly Middleware<C, E, S>[] },
): Runtime<C, E, S>;
interface Runtime<C, E, S> {
getSnapshot(): Snapshot<C, S>;
send(event: E): Snapshot<C, S>;
subscribe(listener: (snap: Snapshot<C, S>) => void): () => void;
reset(event?: E): Snapshot<C, S>;
dispose(): void;
readonly disposed: boolean;
readonly signal: AbortSignal;
}Thin wrapper. Internally calls step() and dispatches effects. dispose() aborts the built-in AbortController, clears listeners, and causes subsequent send() / reset() calls to throw RuntimeDisposedError. reset() rewinds the snapshot to initialSnapshot(def) and notifies subscribers, but does not run entry actions — reset is "the runtime is reborn", not a transition.
runtime.signal is the runtime's lifetime signal; it fires once on dispose. Every EffectHandler receives it via args.signal. External integrations (React unmount, game scene teardown) can attach runtime.signal.addEventListener("abort", ...) to chain their own cleanup.
step(def, snapshot, event, impl)
function step<C, E, S>(
def: MachineDef<C, E, S>,
snapshot: Snapshot<C, S>,
event: E,
impl: Implementations<C, E>,
): { snapshot: Snapshot<C, S>; effects: readonly Effect[] };Pure function. The invariant keeper for the whole library. It never dispatches effects, never mutates the snapshot, and never throws — a failing guard or unmapped event simply returns the original snapshot.
assign(updater)
function assign<C, E>(
updater: (args: { context: C; event: E }) => Partial<C>,
): Action<C, E>;Pure context update helper. Returns a partial that is merged into the context. No side effects.
Opt-in Modules
Each opt-in lives on its own subpath. If you don't import it, it is fully tree-shaken away.
aifsmjs/guards — Guard combinators
import { and, or, not, stateIn } from "aifsmjs/guards";
const canCheckout = and([
"isAuthenticated",
or(["isAdmin", "isOwner"]),
not("isBanned"),
]);and/or/not short-circuit over sync guards. stateIn(...states) is a sugar predicate: "current state is one of these".
aifsmjs/effects — Fire-and-forget effects
import { type Action } from "aifsmjs";
const checkout: Action<Ctx, Evt> = ({ context, enqueue }) => {
enqueue.effect("trackAnalytics", { event: "checkout", ctx: context });
// Return value becomes the new context (omit to keep current context)
};enqueue.effect(type, payload) queues a side-effect declaration. step() collects them and hands them back to the caller. Runtime dispatches after the transition; replay mode disables dispatch and keeps only the snapshot fold.
aifsmjs/inspect — Read-only middleware
import { createRuntime } from "aifsmjs";
import { logger, persist } from "aifsmjs/inspect";
const runtime = createRuntime(def, impl, {
middleware: [
logger(console.log),
persist({ key: "machine-state", storage: localStorage }),
],
});Koa-style (ctx, next) => void pipeline. ctx is { prev, next, event, effects }, all structuredCloned and frozen. Cannot cancel a transition — next() must be called; the return value carries no meaning.
aifsmjs/replay — Pure event log replay
import { replay } from "aifsmjs/replay";
const finalSnap = replay(initialSnapshot, eventLog, def, impl);
// Equivalent to eventLog.reduce((s, e) => step(def, s, e, impl).snapshot, initial)Never dispatches effects. For PBT, time-travel debugging, and incident reproduction.
aifsmjs/pbt — fast-check adapter
Install the peer:
pnpm add -D fast-check(^3.20.0). aifsmjs lists fast-check as an optional peer; you only need it when importing this subpath.
import fc from "fast-check";
import { commandsFromMachine, properties } from "aifsmjs/pbt";
fc.assert(
fc.property(
commandsFromMachine(def, impl, {
NEXT: fc.constant({ type: "NEXT" as const }),
}),
(cmds) => properties.runDeterministic(def, impl, cmds),
),
);properties.* ships 6 generic properties (see Testing Strategy). fast-check is peerDependenciesMeta.optional; no install penalty if you don't use it.
aifsmjs/timer — Cancellable delayed callbacks
import { after, createScheduler } from "aifsmjs/timer";
// One-shot
const handle = after(5000, () => runtime.send({ type: "TIMEOUT" }));
handle.cancel(); // cancels if not yet fired
// AbortSignal integration
const ac = new AbortController();
after(5000, () => runtime.send({ type: "TIMEOUT" }), { signal: ac.signal });
ac.abort(); // also cancels
// Scheduler: bundle a group of timers and cancel them together on teardown
const sched = createScheduler();
sched.after(1000, () => {});
sched.after(2000, () => {});
sched.cancelAll();- Thin wrapper over
setTimeout/clearTimeout, with injectable timer functions (validated by vitest fake timers) - AbortSignal listener registered with
{ once: true }to avoid leaks - Decoupled from the FSM core: you decide when to forward a fired timer as
runtime.send(...)
Lifecycle Invariants
The fixed order inside step() (always, no escape hatch):
1. resolveTransitions(def, snapshot.value, event)
→ candidate transitions for (state, event)
2. evaluate guard on each candidate in declaration order
→ first passing transition is chosen; otherwise the original snapshot is returned
3. exit actions of the old state (v1 is flat, no hierarchy)
4. transition.actions[] run in declaration order
→ each action may call enqueue.effect()
→ each action's returned partial context is merged into the current context
5. entry actions of the new state
6. return { snapshot, effects } — the caller decides when to dispatch effectsContracts:
Guarantees:
- Guards are sync and pure (never mutate context)
- Actions always run to completion (no cancel mechanism)
- Effects are declarations (type + payload), not callbacks — serializable
- Snapshot is immutable; dev mode deep-freezes for diagnostics, prod is shallow for speed
Non-goals:
- No async lifecycle hook
- Inspect middleware cannot alter the transition outcome
Sub-machine lifecycle (stable since 0.4.0)
When a state declares sub, the per-transition ordering is:
- Parent
step()runs:exit actions → transition.actions → entry actions. - Old child (if any)
dispose()— synchronous; exceptions becomeSubMachineError(phase: "dispose"). - New child (if next state has
sub) instantiation — exceptions becomeSubMachineError(phase: "init"). - Parent snapshot commits.
- Middleware pipeline runs.
- Effects dispatch.
'transition'event emits toon()/onTransition()subscribers.
If step 2 or 3 throws, the parent snapshot is not committed (rollback
to prev); no middleware / effects / 'transition' runs.
runtime.dispose() cascades to the child via controller.signal's abort
listener and an explicit child.dispose() call. Cascade swallows child
exceptions to honour the never-throws dispose contract.
Lifecycle Protocol
aifsmjs is the first package in a "minimal AI toolchain" family. This lifecycle protocol is meant to be reused by future packages (aitaskjs / aibridgejs / aiaudiojs and friends):
| Verb | aifsmjs equivalent | Semantics |
|---|---|---|
| createX() | createRuntime / createScheduler / defineMachine / setup | Factory function returning the instance |
| dispose() | runtime.dispose() / scheduler.cancelAll() | Release resources; idempotent; post-dispose API throws a known error |
| reset() | runtime.reset() | Zero out state without releasing resources |
| on/off | runtime.subscribe(fn) returning an unsubscribe fn | Subscription pattern; explicit unsubscribe |
| AbortSignal | runtime.signal / after(_, _, { signal }) | Cancellation channel for any long-running / async work |
| Pure core | step() | No I/O, serializable, replayable |
| Explicit errors | RuntimeDisposedError / UnknownGuardError / UnknownActionError / InvalidDefinitionError | Named error classes, never bare throw "string" |
When future ai*js packages ask "should this have a dispose?" or "where does the signal plug in?", this table is the baseline.
Design choices: divergence from common patterns
aifsmjs ships a few opinionated calls that look different from the typical FSM library. The rationale below explains what we chose and why, so readers coming from XState, statecharts, or general event-emitter libraries can skip the source dive.
send()is synchronous, returningSnapshotinstead ofPromise<Snapshot>. The purestep()core is sync by construction so thatreplay(initial, log)and PBT shrinking remain trivial. Effect handlers may still be async; the runtime fires them and forwards async rejections to the'error'event channel. If you need to await effect completion, build a small wrapper that returnsPromise.allover your handler results.- Guards and reducers are sync. A non-deterministic guard would break the PBT determinism property (#1 in the generic suite). Move async predicates into events: send
FETCH_REQUEST, then laterFETCH_DONEwith the resolved value as payload. - Effects are descriptors, not inline callbacks. Actions enqueue
{ type, payload }viaenqueue.effect(...); the runtime collects them and the dispatcher invokes user handlers. This keeps machine definitions serializable (JSON round-trippable when no inline functions are used), enablesreplay()to fold an event log into the same snapshot, and letsinspect/persistmiddleware capture effects for audit logs. - Two factory paths coexist.
setup<Ctx, Evt>().defineMachine(...)is the type-friendly form (States inferred fromkeyof states).createMachine(def, impl, opts?)is the spec-style single-factory shortcut from the ai*js ecosystem review. PlaindefineMachine<Ctx, Evt, States>(def)remains for explicit generic control. Pick whichever reads best at the call site. subscribe(listener)andon(type, fn, { signal, once })both exist. The typedon()matches the platformEventTargetsemantics (signal + once) and emits'transition','error','dispose'. The oldersubscribe()keeps the ReactuseSyncExternalStoreshape — pass it directly. They are not exclusive.
AI-Agent Reading Guide
This section is for LLMs and code-search agents. Invariants, types, and misuse patterns are concentrated here.
Serializable fields
The following are plain data, safe to JSON.stringify round-trip:
- The entire
MachineDef(provided no inline functions are used) - The entire
Snapshot(providedcontextis plain data) - The entire
Effect({ type: string; payload?: unknown })
The following are not serializable and will break PBT/replay:
- Every function inside
Implementations - Middleware closures
Invariants (do not violate)
step()is pure: identical(def, snapshot, event, impl)always returns identical{ snapshot, effects }.- Snapshots are frozen: in dev mode any mutation throws immediately.
- Guards never mutate context: violators are caught by PBT property #2.
- Effects are always fire-and-forget: the runtime never waits for an effect before updating the snapshot.
dispose()is idempotent; post-disposesend()/reset()throwsRuntimeDisposedError.runtime.signal.abortedistruefor the rest of time once disposed; the effect handler'ssignalis the same one.reset()only resets the snapshot and notifies listeners — it does not run entry actions. Listeners are notified only whenprev.value !== initial.value(parity withsend()). Middleware always observes the call (possibly withchanged: false).MiddlewareContext.eventis typedEvt | ResetEvent; areset()without an event injects theRESET_EVENT_TYPEsentinel ("@@aifsmjs/RESET").
Common misuses
| Anti-pattern | Correct form |
| --------------------------------------------------------- | ------------------------------------------------------------- |
| Calling fetch() (or any async API) inside a guard | Rewrite as events: send FETCH_REQUEST, then FETCH_DONE |
| setTimeout-and-mutate inside an action | Use enqueue.effect("delayedThing", ...) |
| Using middleware to alter the next state | Not possible — middleware is read-only. Rewrite as a guard. |
| Inline functions inside a definition (works but breaks serialize) | Pull out as string refs, inject at createRuntime |
Machine-readable schema
A JSON schema for MachineDef will ship at dist/schema/machine.schema.json. Not yet available in v1; types live in src/fsm/types.ts for agents to derive from.
Testing Strategy
Example-first, PBT-augmented. Lesson from jssm: "3000+ tests / 100% coverage" turns out to have < 12% coverage from stochastic tests — the rest is example specs.
- Example tests (vitest): for every src module, write happy path + edge + error-message triplets.
- PBT smoke: each generic property runs 50 iterations as an invariant guard, not as a coverage source.
- CI-enforced thresholds:
@vitest/coverage-v8is wired to 100% statements / 100% lines / 100% functions / ≥90% branches. The few defensive invariant-guard branches (e.g. runtime determinism mismatch) carry/* v8 ignore */annotations with rationale. - Size budget:
scripts/check-size.mjsenforces per-subpath gzip caps in CI — core ≤4.7 KB (raised in 0.3.0 for sub-machine sugar), replay ≤1.8 KB, pbt ≤5.5 KB (raised in 0.3.0 becausepbttransitively importscreateRuntime), others ≤1 KB. Exceeding any cap fails the build.
The 6 built-in generic properties
| # | Property | One-liner |
| --- | --------------------------------- | -------------------------------------------------------- |
| 1 | snapshotAlwaysFrozen | After any event sequence, the snapshot remains frozen |
| 2 | unknownEventNoOp | Undeclared events do not change the snapshot |
| 3 | reachableStatesSubsetDeclared | Every reachable state belongs to def.states |
| 4 | replayEqualsFold | replay(init, log) equals events.reduce(step) |
| 5 | guardsFalseNoTransition | When all guards fail, the state is unchanged |
| 6 | assignDoesNotMutate | assign never modifies the previous context |
Comparison
| | aifsmjs | XState v5 | Robot3 | @xstate/store | Zag.js | | -------------------------- | -------------- | ----------------- | ----------------- | ----------------- | ----------------- | | Core size (gzip) | ~2.8KB | ~15KB | ~1KB | < 1KB | per-component | | Hierarchical states | Sugar (0.3.0) | Yes | No | N/A | Yes | | Async invoke / actor | No | Yes | No | N/A | No | | Guard combinators | and/or/not | and/or/not | No | N/A | No | | Effects dual-track | enqueue | enqueueActions | reduce/action | enq.effect() | array of names | | Inspect / observe | read-only | inspect API | No | proposed | watch ctx | | Serializable definition | Yes | Yes | Partial | Partial | Yes | | fast-check adapter | built-in | No | No | No | No | | Tree-shake subpath imports | Yes | Partial | Yes | Yes | Yes |
Roadmap
| Version | Scope |
| ------- | ------------------------------------------------------------------ |
| v0.1 | core + guards + effects + inspect + replay + pbt (this release) |
| v0.2 | Async-guard detection, coverage tuning, llms-full.txt verify gate |
| v0.3 | Hierarchical sugar via state.sub (experimental) |
| v0.4 | Sub-machine API promoted to stable; dependency-reduction cycle |
| v0.5 | aifsmjs-bridge-bitecs / aifsmjs-bridge-pixi (separate sub-packages) |
| v1.0 | API freeze and stability guarantee |
Out of scope (v1):
- Parallel state regions (out of scope for v1)
- Actor invocation / spawn (out of scope for v1)
- Tick / game-loop hook (out of scope for v1)
- ECS / Pixi bridges (out of scope for v1)
Future candidate: historyState — remember last active sub-state on
re-entry. Workaround today: snapshot via onTransition and restore
manually.
