An asynchronous finite state machine library for Node.js and the browser.
This library requires async/await
and Proxy
support in your Node.js runtime, so ideally node>=7.4
.
$ npm install --save @f5io/machine
or
$ yarn add @f5io/machine
With this library, a finite state machine is defined with an object containing a list of transitions, in the format:
{
[transitionName]: { from: [...states], to: [...states] },
...more transitions
}
A named transition
defines an edge on a graph that allows a transition between the from
and to
states. The supplied from
and to
properties can either be a singular state string or an array of state strings.
Further to this, a state machine can be supplied with handlers
which hook into the life-cycle of the machine. A state transition would flow through handlers
in a particular order:
onBefore{T} -> onLeave{CS} -> on{T} -> onEnter{TS} -> on{TS} -> onAfter{T}
Where T
is equal to a transition
name, CS
is equal to the current state and TS
is equal to the new target state.
For example, on a machines' transition from state A
to state B
over a transition foo
, the handlers
order would fire like so:
onBeforeFoo -> onLeaveA -> onFoo -> onEnterB -> onB -> onAfterFoo
These handlers
are supplied the context that is used at initialisation of the machine. In contrast to many other state machine implementations, a state machine created by this library can be initialised in any state without forced transitioning. This allows state machines to be wrapped over data structures at any time in their life-cycle.
The library exposes one function which is used to create a machine factory.
createMachineFactory({ stateKey = 'state', allowCyclicalTransitions = false, handlers = {}, transitions })
-> MachineFactory
The machine factory creator takes an options object containing 3 properties:
stateKey
- defaults to'state'
, determines the key on which the state will be defined on the contextallowCyclicalTransitions
- defaults tofalse
, determines whether the machine should allow cyclical transitionshandlers
- defaults to{}
, defines optional life-cycle hooks for the machinetransitions
- required, defines transitions keyed by name containingfrom
/to
attributes of the typestring|number|array<string|number>
A machine factory is returned which is used to initialise a machine.
This function requires a context
object to be passed in. It will check whether a valid state is defined at context[stateKey]
(see above).
The returned Machine
will contain the following default methods:
Machine.can(to)
->Boolean
- Thecan
method takes a state to transition to and will return aBoolean
as to whether the machine can transition directly to that stateMachine.to(to)
->Promise
- Theto
method will attempt to transition the machine to the supplied state, otherwise will throw an error if unavailableMachine.edge(to)
->string
- Theedge
method will return the name of the transition that fulfils the transtion to the supplied state, otherwise will throw an error if none is availableMachine.will(...to)
->Boolean
- Thewill
method takes any number of states and attempts to find a shortest path between the current state and each state supplied, eventually ending at the last supplied state, returning aBoolean
Machine.thru(...to)
->Promise
- Thethru
method, similarly to thewill
method, takes any number of states and attempts to find a shortest path between the current state and each state supplied, eventually ending at the last supplied state, then enacts the change to the machine by transitioning through all the statesMachine.path(...to)
->Boolean|Array<Array<String>>
- Thepath
method, similarly to thewill
method takes any number of states and returns eitherfalse
denoting an invalid transition, or anArray
of pair tuples, ie.[ ['A', 'B'], ['B', 'C'] ]
denoting the state changes it would take to achieve the transition, without effecting any changeMachine.transitions
->array<string>
- Thetransitions
methods will return an array of all available transition names from the current state
The Machine
also will contain methods that are derived from the transitions
object passed to the createMachineFactory
function. For example, given the transitions object:
{
foo: { from: 'A', to: 'B' },
bar: { from: 'B', to: 'C' },
}
The Machine
will have both a foo
and a bar
method which both return a Promise
and, once called, enact that transition on the machine.
Below is a simple state machine example and how it could be used.
The createMachineFactory
function expects a configuration object that contains the parameters for the state machine including transitions and life-cycle behaviour. This function will return a factory method (machineFactory
below) that, when called, will create an instantiated instance of the defined state machine with a given context.
const machineFactory = createMachineFactory({
/**
* The transitions describe all the states the machines can be in
* and the transitions available between those states.
*/
transitions: {
init: { from: 'A', to: 'B' },
effect: { from: [ 'A', 'B', 'D' ], to: 'C' },
dispute: { from: 'C', to: 'D' }
},
/**
* Handlers can be optionally supplied for any life-cycle
* event available on the machine. All handlers are run
* asynchronously.
*/
handlers: {
onInit: (ctx) => {
/**
* you can mutate the context, however you will not
* be able to directly mutate the `ctx[stateKey]`.
*/
ctx.hasInitialised = true;
},
onEffect: async (ctx) => {
/**
* all handlers are run asynchronously.
*/
await timeout(200);
}
},
/**
* The state key option defines the key of the state within
* the context object that will be supplied to the `initMachine`
* function.
*/
stateKey: 'stateId',
});
Once you have created your machineFactory
function, you can instantiate instances of your machine with a given context
object. This context
object must contain the attribute defined by stateKey
in the createMachineFactory
method, and the value of this key must be a valid state (as derived from the supplied transitions
).
const machine = machineFactory({
stateId: 'A',
anything: 'canBeSupplied',
functionality: () => 'foo',
etc: [ 1, 2, 3 ],
});
The machine is now constructed and has some default methods, plus methods that are derived from your transition
names.
(async () => {
/**
* `can` is a default method which takes a state and will
* will return a boolean as to whether the machine can
* directly transition to the supplied state.
*/
if (machine.can('B')) { // returns true as there is an edge from `A` to `B`
/**
* `edge` is a default method which takes a state to transition
* to and will return the name of a transition if available.
*/
const edge = machine.edge('B'); // returns `init`
/**
* `to` is a default method which takes a state to attempt
* to transition to. If it is in an invalid transition it will
* throw an error.
*/
await machine.to('B');
/**
* The machine is now in state `B` and according to the `onInit` handler
* `machine.hasInitialised` should exist and be equal to `true`. As described
* above, the machine also exposes methods derived from the supplied `transition`
* names.
*/
await machine.effect();
/**
* The machine has taken around 200ms to transition into state `C` as defined
* by the `timeout` in the `onEffect` handler.
*/
}
})();
The library also contains a mechanism for transitioning along a shortest path to a desired state.
const machine = initMachine({
stateId: 'A',
});
(async () => {
/**
* `will` is a default method that takes a variadic number of states to pass thru
* on it's way to the target state, and will return a boolean as to whether the transition can be
* achieved ie. from the current state to the last in the arguments via the rest of the arguments.
* The following `will` call is equivalent to `machine.will('C', 'D')`.
*/
if (machine.will('D')) { // the machine has found a path to `D` thru `C` from `A`
/**
* `path` is a default method which will return an array of tuples denoting the changes in state
* that will be made to enact change. In this case it will return `[ ['A', 'C'], ['C', 'D'] ]`.
* If `path` is passed states that make up an invalid transition, it will simply return `false`.
*/
const pairs = machine.path('D');
/**
* `thru` is a default method that will enact a chain of state changes to reach the supplied
* target state. It works in the same way as the `will` method.
*/
await machine.thru('D');
/**
* The machine is now in state `D` having transitioned through every state on the way, in the
* shortest possible path and passing through each of the handlers.
*/
}
})();
A command-line application is included within the package for creating svg diagrams of a defined state machine.
$ `npm bin`/visualise --help
Usage: visualise [options]
a tool for outputting svgs from finite state machines
Options:
-V, --version output the version number
-i, --input <value> input to be visualised in the format .json, .js or .dot
-g, --graph <value> supply a name for the graph
-f, --format <value> output format, either .svg or .dot, defaults to .svg
-o, --output <value> output to file, if none supplied will output to stdout
-s, --styles <value> supply a css file of .dot styles
-h, --help output usage information
Supported input types include:
.js
files, which default export is a machine initialiser, ie. see here..json
files, which define transitions for a machine, ie. see here..dot
files, which define a graphviz representation of a graph, ie. see here.
Output can be either .dot
or .svg
and can be styled with a CSS-like syntax, shown here. Below is an example of the svg output of using the following command with examples from this repo.
$ `npm bin`/visualise -i ./test/test.fsm.js -s ./test/test.fsm.css > ./test/test.fsm.svg
Contributions are welcomed and appreciated!
- Fork this repository.
- Make your changes, documenting your new code with comments.
- Submit a pull request with a sane commit message.
Feel free to get in touch if you have any questions.
Please see the LICENSE
file for more information.