npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2024 – Pkg Stats / Ryan Hefner

@ersbeth/picomachine

v0.0.2

Published

Minimal statecharts library for JS/TS

Downloads

1

Readme

Picomachine

A tiny full-featured library for composable finite state machines and statecharts written in TypeScript:

  • enter/exit/transition hooks for side effects
  • nested states
  • parallel states
  • transient states
  • multiple transitions
  • guarded transitions
  • delayed transitions
  • async (promise-based) states
  • history
  • child/parent communication
  • splitting and composition

@ersbeth/picomachine aims at providing a seamless developper experience. The API is carefully crafted to be legible and allows easy splitting and composition of statemachines.

The library runs both in browser and on Node.js

Installation

npm install @ersbeth/picomachine

Usage

Finite state machine

Basic example

Here is a simple toggle machine. Comments show what is logged in the console.

import { StateMachine } from "@ersbeth/picomachine"

// Machine structure definition
const machine = new StateMachine("root", {
    type: "nested",
    initial: 'OFF', // initial state
    states: { // keys are states names
        ON: {
            events: { // keys are events names
                "TOGGLE": { target: "OFF" }
            }
        },
        OFF: {
            events:
            {
                "TOGGLE": { target: "ON" }
            }
        }
    }
});

// Add listener
machine.status.onChanged((status) => console.log(status))

// Start machine
machine.start();
// -> [ 'root' ]
// -> [ 'root.OFF' ]

machine.send('TOGGLE');
// -> [ 'root.ON' ]

machine.send('TOGGLE');
// -> [ 'root.OFF' ]

Actions

Actions are functions which perform side-effects. FSM have several hooks where you can call actions:

  • when entering a state
  • when leaving a state
  • when executing a transition

The hooks execution order is exit>transition>enter .

warning: actions should always be sync function. If you need to perform promised-based side-effect use activities instead (see below).

import { StateMachine } from "@ersbeth/picomachine"

const actions = {
    enterON: () => console.log("enter ON"),
    exitON: () => console.log("exit ON"),
    enterOFF: () => console.log("enter OFF"),
    exitOFF: () => console.log("exit OFF"),
    toON: () => console.log("to ON"),
    toOFF: () => console.log("to OFF")
}

const machine = new StateMachine("root", {
    type: "nested",
    initial: 'OFF',
    states: {
        ON: {
            enter: actions.enterON, // enter actions (can be an array)
            exit: actions.exitON, // exit actions (can be an array)
            events: {
                "TOGGLE": { target: "OFF", actions: actions.toOFF } // transition actions (can be an array)
            }
        },
        OFF: {
            enter: actions.enterOFF,
            exit: actions.exitOFF,
            events:
            {
                "TOGGLE": { target: "ON", actions: actions.toON }
            }
        }
    }
});

// Add listener
machine.status.onChanged((status) => console.log(status))

// Start machine
machine.start();
// [ 'root' ]
// enter OFF
// [ 'root.OFF' ]

machine.send('TOGGLE');
// exit OFF
// to ON
// enter ON
// [ 'root.ON' ]

machine.send('TOGGLE');
// exit ON
// to OFF
// enter OFF
// [ 'root.OFF' ]

It is possible to send a data object with an event to the state machine :

machine.send('EVENT', someData)

This data object will be passed as parameter to the actions of the triggered transition where you can use it :

events: {
  EVENT: {target: "SOMEWHERE", actions: (data) => doStuff(data)}
}

Self transitions

It is possible to transit from a node to itself. There are two types of self-transtions:

  • external : it will trigger enter/exit hooks. For this use "self" as target.
  • internal: it will not trigger enter/exit hooks. For this use "internal" as target

Guarded transitions

You can add guards to transitions so that they trigger only under certain circumstances. For example here when going out we take an umbrella if it's raining or a hat if it's hot:

import { StateMachine } from "@ersbeth/picomachine"

const guards = {
    isRaining: () => true, // return something that depends on the context
    isHot: () => false
}

const machine = new StateMachine("root", {
    type: "nested",
    initial: 'GOING_OUT',
    states: {
        GOING_OUT: {
            events: {
                "CHECK_WEATHER": [
                    { target: "TAKING_UMBRELLA", guard: guards.isRaining }, // guard is true so we take this one
                    { target: "TAKING_HAT", guard: guards.isHot }
                ]
            }
        },
        TAKING_HAT: {},
        TAKING_UMBRELLA: {},

    }
});

machine.status.onChanged((status) => console.log(status))

machine.start();
// [ 'root' ]
// [ 'root.GOING_OUT' ]

machine.send('CHECK_WEATHER');
// [ 'root.TAKING_UMBRELLA' ]

Transitions are evaluated in the same order as they are declared in the array. The first whith a true guard is taken, and the other are ignored.

Delayed transitions

The execution of a transition can be delayed by adding a delay field to it:

...
events: {
  MY_EVENT: { target: "SOMEWHERE", delay: 500}
}
...

if we send "MY_ENVENT" to the state machine, the transition will execute after 500ms.

  • if the transition is guarded, the guard is evaluated immediately.
  • if the state machine exits the state before the end of the delay then the transition is canceled.

Transient transitions

A transient transition is automatically triggered on enter. These transitions are useful when combined with guards. Use the always field to declare them:

...
MY_STATE: {
  always: {target: "SOMEWHERE"}
}
...

A transient transition is always executed after the enter actions.

Nested States

Basic example

Just add a states field with substates and an initial state. Here is a basic example of a player machine. The player can be toggled ON/OFF, and when it's ON it can be PLAYING or PAUSED :

import { StateMachine } from "@ersbeth/picomachine"

const actions = {
    enterON: () => console.log("enter ON"),
    exitON: () => console.log("exit ON"),
    enterOFF: () => console.log("enter OFF"),
    exitOFF: () => console.log("exit OFF"),
    enterPLAYING: () => console.log("enter PLAYING"),
    exitPLAYING: () => console.log("exit PLAYING"),
    enterPAUSED: () => console.log("enter PAUSED"),
    exitPAUSED: () => console.log("exit PAUSED"),
    toON: () => console.log("to ON"),
    toOFF: () => console.log("to OFF"),
    play: () => console.log("play"),
    pause: () => console.log("pause"),
}

const machine = new StateMachine("root", {
    type: "nested",
    initial: 'OFF',
    states: {
        ON: {
            type: "nested",
            enter: actions.enterON,
            exit: actions.exitON,
            events: {
                "TOGGLE": { target: "OFF", actions: actions.toOFF }
            },
            initial: "PLAYING",
            states: {
                PLAYING: {
                    enter: actions.enterPLAYING,
                    exit: actions.exitPLAYING,
                    events: {
                        "PAUSE": { target: "PAUSED", actions: actions.pause }
                    }
                },
                PAUSED: {
                    enter: actions.enterPAUSED,
                    exit: actions.exitPAUSED,
                    events: {
                        "PLAY": { target: "PLAYING", actions: actions.play }
                    }
                }
            }
        },
        OFF: {
            enter: actions.enterOFF,
            exit: actions.exitOFF,
            events:
            {
                "TOGGLE": { target: "ON", actions: actions.toON }
            }
        }
    }
});

// Add listener
machine.status.onChanged((status) => console.log(status))

// Start machine
machine.start();
// [ 'root' ]
// enter OFF
// [ 'root.OFF' ]

machine.send('TOGGLE');
// exit OFF
// to ON
// enter ON
// [ 'root.ON' ]
// enter PLAYING
// [ 'root.ON.PLAYING' ]

machine.send('PAUSE');
// exit PLAYING
// pause
// enter PAUSED
// [ 'root.ON.PAUSED' ]

machine.send('TOGGLE');
// exit PAUSED
// exit ON
// to OFF
// enter OFF
// [ 'root.OFF' ]

Final states

Final states are declared with type="error" or type="done". When entering this states, the transition "error" or "done" is triggered on the parent. Here is an example of a machine which retries a computation until it gets a result:

import { StateMachine } from "@ersbeth/picomachine"

const machine = new StateMachine("root", {
    type: "nested",
    initial: 'COMPUTE',
    states: {
        COMPUTE: {
            type: 'nested',
            done: { target: "RESULT" },
            error: { target: "self" }, // will re-trigger computations until we get a result

            initial: "CALCULATE",
            states: {
                CALCULATE: {
                    events: {
                        "SUCCEED": { target: "DONE" },
                        "FAIL": { target: "ERROR" },
                    }
                },
                ERROR: { type: "error" },
                DONE: { type: "done" },
            }
        },
        RESULT: {}
    }
});

machine.status.onChanged((status) => console.log(status))

machine.start();
// [ 'root' ]
// [ 'root.COMPUTE' ]
// [ 'root.COMPUTE.CALCULATE' ]

machine.send('SUCCEED');
// [ 'root.COMPUTE.DONE' ]
// [ 'root.RESULT' ]

It is possible to have multiple error and done states with custom names :

// default final states
ERROR:{}
DONE:{}
// custom final states
MY_ERROR: {
  type: "error"
}
MY_DONE: {
  type: "done"
}

The done and error transition are called with the name of the reached final state as argument.

Such In case of multiple final/error states you can use guarded transitions (see below) to decide what to do on the parent:

PARENT: {
  done: [
    {target: "OUTPUT_1", guard: (data)=> data=="MY_DONE_1"},
    {target: "OUTPUT_2", guard: (data)=> data=="MY_DONE_2"},
  ]
  ...
}

History state

History can be activated in a state. It will keep track of the last active substate before quiting the state. When entering back the state the machine will enter the substate kept in history instead of the initial state. Let's take a look back at our previous player example. Now with history enabled when we switch on the player it will go to the last state where it was before:

import { StateMachine } from "@ersbeth/picomachine"

const machine = new StateMachine("root", {
    type: "nested",
    initial: 'OFF',
    states: {
        ON: {
            type: "nested",
            history: true, // history activation
            events: {
                "TOGGLE": { target: "OFF" }
            },

            initial: "PLAYING",
            states: {
                PLAYING: {
                    events: {
                        "PAUSE": { target: "PAUSED" }
                    }
                },
                PAUSED: {
                    events: {
                        "PLAY": { target: "PLAYING" }
                    }
                }
            }
        },
        OFF: {
            events:
            {
                "TOGGLE": { target: "ON" }
            }
        }
    }
});

machine.status.onChanged((status) => console.log(status))

machine.start();
// [ 'root' ]
// [ 'root.OFF' ]

machine.send('TOGGLE');
// [ 'root.ON' ]
// [ 'root.ON.PLAYING' ]

machine.send('PAUSE');
// [ 'root.ON.PAUSED' ]

machine.send('TOGGLE');
// [ 'root.OFF' ]

machine.send('TOGGLE');
// [ 'root.ON' ]
// [ 'root.ON.PAUSED' ] Here without history we would have been in PLAYING state

Parallel states

Basic example

To make a parrallel state you must activate the parallel flag. Here is an example of a styling machine which manages bold/underline/italics in parallel :

import { StateMachine } from "@ersbeth/picomachine"

const machine = new StateMachine("root", {
    type: "parallel",
    states: {
        BOLD: {
            type: "nested",
            initial: 'OFF',
            states: {
                ON: {
                    events: {
                        "TOGGLE_BOLD": { target: "OFF" }
                    }
                },
                OFF: {
                    events: {
                        "TOGGLE_BOLD": { target: "ON" }
                    }
                }
            }
        },
        UNDERLINE: {
            type: "nested",
            initial: 'OFF',
            states: {
                ON: {
                    events: {
                        "TOGGLE_UNDERLINE": { target: "OFF" }
                    }
                },
                OFF: {
                    events: {
                        "TOGGLE_UNDERLINE": { target: "ON" }
                    }
                }
            }
        },
        ITALICS: {
            type: "nested",
            initial: 'OFF',
            states: {
                ON: {
                    events: {
                        "TOGGLE_ITALICS": { target: "OFF" }
                    }
                },
                OFF: {
                    events: {
                        "TOGGLE_ITALICS": { target: "ON" }
                    }
                }
            }
        },
    }
});

machine.status.onChanged((status) => console.log(status))

machine.start();
// [ 'root.BOLD', 'root.UNDERLINE', 'root.ITALICS' ]
// [ 'root.BOLD.OFF', 'root.UNDERLINE', 'root.ITALICS' ]
// [ 'root.BOLD.OFF', 'root.UNDERLINE.OFF', 'root.ITALICS' ]
// [ 'root.BOLD.OFF', 'root.UNDERLINE.OFF', 'root.ITALICS.OFF' ]

machine.send('TOGGLE_BOLD');
// [ 'root.BOLD.ON', 'root.UNDERLINE.OFF', 'root.ITALICS.OFF' ]

machine.send('TOGGLE_UNDERLINE');
// [ 'root.BOLD.ON', 'root.UNDERLINE.OFF', 'root.ITALICS.OFF' ]

machine.send('TOGGLE_ITALICS');
// [ 'root.BOLD.ON', 'root.UNDERLINE.ON', 'root.ITALICS.ON' ]

Final states

There isn't final states in a parrallel state, but substates can have final states inside them.

  • the done transition is triggered when all the substates wich have final states are done
  • the error transition is triggered when one of the substate which have error states is in error.

Child/Parent communication

Any state can raise an event on its parent trough an action. This is especially useful to allow communication between children of a parrallel state. The raised event is added to the javascript event queue, so that the current transition can end up before starting a new one.

Here is an example of parrallel children communication:

import { StateMachine } from "@ersbeth/picomachine"

const actions = {
    doStuffB: () => console.log("Do stuff B"),
}

const machine = new StateMachine("root", {
    type: "parallel",
    states: {

        STATE_A: {
            events: {
                DO_STUFF_A: { target: "self", actions: "DO_STUFF_B" }
            }
        },

        STATE_B: {
            events: {
                DO_STUFF_B: { target: "self", actions: actions.doStuffB },
            }
        },
    }
});

machine.status.onChanged((status) => console.log(status))

machine.start();
// ['root.STATE_A', 'root.STATE_B']

machine.send('DO_STUFF_A');
// [ 'root.STATE_A', 'root.STATE_B' ]
// Do stuff B
// [ 'root.STATE_A', 'root.STATE_B' ]

Activity States

You will often have to perform side-effects with are promised-based (eg. fetching data from the network). We call these side-effects "activities". They can be managed with an activity state, which has built-in error and done hooks that are triggered when the promise resolves. Let's see our computation example with a promised-base function:

import { StateMachine } from "@ersbeth/picomachine"

const activities = {
    calculate: () => Promise.resolve()// return  instead a promise whose resolution depends on the context
}

const machine = new StateMachine("root", {
    type: "nested",
    initial: 'COMPUTING',
    states: {
        COMPUTING: {
            type: "async",
            done: { target: "RESULT" },
            error: { target: "self" }, // will re-trigger computations until we get a result

            activity: activities.calculate // here we declare our activity
        },
        RESULT: {}
    }
});

machine.status.onChanged((status) => console.log(status))

machine.start();
// [ 'root' ]
// [ 'root.COMPUTING' ]
// [ 'root.RESULT' ]

Composition

The structure of a statemachine can be spread across different nodes, either in the same file or in different files. This brings a better modularity and allows to reuse some parts of your machine in different places. To split a machine, use the src field.

// these nodes could be defined in another file and imported here
const stateA = new StateMachine("A", {...})
const stateB = new StateMachine("B", {...})

// combine them under the root node
const machine = new StateMachine("root", {
  states:{
    A:{
      src: stateA, // here you indicate the source node
    },
    B:{
      src: stateB,
    }
  }
})

You can define transitions and actions either in the parent node or in the child node or event in both: everything will be merged.

warning: in the child node you can only define self-transitions as the siblings are not known. you must define siblings transitions on the parent node.

Acknowledgment

This library is heavily inspired by xstate for the API, but the implementation is completely different and much more minimal. If you need more feature than what we provide, go for xstate it's awesome !

License

MIT