@deadb17/state-machine
v1.2.0
Published
Minimally practical state machine
Downloads
13
Readme
State Machine
A minimally practical state machine in JavaScript developed to organize asynchronous systems.
- Minimal: 400 bytes minified. 250 bytes gzipped.
- Practical: Low friction to adopt. It should provide enough value to warrant its inclusion.
- JavaScript: Runs in the browser and Nodejs
- Organize asynchronous systems: It enforces the minimal structure to ease development and maintenance of systems based on asynchronous events.
Installation
npm install --save @deadb17/state-machine
Example
const graphFileName = 'sample.svg';
console.log(`![Example graph](${graphFileName})`);
Define a graph
Start by defining a graph with a plain JavaScript object.
/** @type {Machine.Graph} */
const g = {
a: {
ENTER: callback,
go: { to: ['b'], call: callback },
loop: { to: ['a'], call: callback },
end: { to: ['c'], call: callback },
LEAVE: callback,
},
b: {
ENTER: callback,
go: { to: ['a'], call: callback },
LEAVE: callback,
},
c: null,
};
// Create graph image
import { graphToDot } from './graph-to-dot.js';
import { exec } from 'child_process';
import { writeFile } from 'fs';
const dot = graphToDot(g, 'a');
writeFile('tmp.dot', dot, (err) => {
if (err) throw err;
exec(`dot -Tsvg tmp.dot -o ${graphFileName}`, (err, _stdout, _stderr) => {
if (err) throw err;
});
});
Its keys (a
, b
and c
) represent the current state.
Their value is another object where the keys are the events that the current state responds to.
The value is another object with two keys: to
and call
:
to
is an array of all the possible end states (one of the top level keys:a
,b
andc
here).call
is a callback function that picks which of the states into
should become the next state.
There are two special events: ENTER
and LEAVE
which get called automatically.
ENTER
is called when the state is entered regardless of the previous state.LEAVE
is called when the state is left regardless of the next state. The value for both is a callback function with the same signature ascall
but it doesn't return any value.
Finally, states with null values or that only respond to ENTER
events are considered terminal states.
Define the callback
Next define the callback for each state transition. Here, the same one is reused for simplicity.
/** @type {Machine.Call<Store>} */
function callback(machine, toStates, { type }) {
machine.count++;
machine.stack.push(`${type}: ${machine.state} -> ${toStates[0]}`);
return toStates[0];
}
The callback takes three parameters:
- The machine itself: All machines have the same interface. Each machine can have specific additional properties.
- An array of possible next states. The callback must return one of them.
- The event that triggered the call.
Machine interface
type Machine = Readonly<{
graph: Graph;
state: State;
handleEvent: (event: MiniEvent) => void;
}>;
type MiniEvent = { readonly type: string } | Event;
Create a machine
import { createMachine } from './index.js';
const m0 = createMachine(g, 'a');
createMachine
takes the graph that was defined previously and the initial state as a string.
Optionally, extend the machine with custom properties
/**
* @typedef {object} Store
* @prop {number} count
* @prop {string[]} stack
*/
/** @type {Store} */
const s0 = { count: 0, stack: [] };
/** @type {Machine.Machine & Store} */
const m = Object.assign(m0, s0);
import { strict as assert } from 'assert';
this results in a machine with the following properties:
assert.equal(m.state, 'a');
assert.equal(m.graph, g);
assert.equal(m.count, 0);
assert.deepEqual(m.stack, []);
Notice that a.ENTER
didn't get called in this case as the machine is not transitioning from another state when it is started. It will get called later when it is a transition.
Send events
Sending the go
event:
m.handleEvent({ type: 'go' });
Results in:
Transitioning to state
b
.Increment the counter to
3
, meaning that three calls were made:go
when going froma
tob
.LEAVE
when going froma
tob
.ENTER
when going froma
tob
.
assert.equal(m.state, 'b');
assert.equal(m.count, 3);
assert.deepEqual(m.stack, ['go: a -> b', 'LEAVE: a -> b', 'ENTER: a -> b']);
Sending the go
event now:
m.handleEvent({ type: 'go' });
assert.equal(m.state, 'a');
assert.equal(m.count, 6);
assert.deepEqual(m.stack, [
'go: a -> b',
'LEAVE: a -> b',
'ENTER: a -> b',
'go: b -> a',
'LEAVE: b -> a',
'ENTER: b -> a',
]);
Sending the loop
event:
m.handleEvent({ type: 'loop' });
assert.equal(m.state, 'a');
assert.equal(m.count, 7);
assert.deepEqual(m.stack, [
'go: a -> b',
'LEAVE: a -> b',
'ENTER: a -> b',
'go: b -> a',
'LEAVE: b -> a',
'ENTER: b -> a',
'loop: a -> a',
]);
Sending the end
event:
m.handleEvent({ type: 'end' });
assert.equal(m.state, 'c');
assert.equal(m.count, 9);
assert.deepEqual(m.stack, [
'go: a -> b',
'LEAVE: a -> b',
'ENTER: a -> b',
'go: b -> a',
'LEAVE: b -> a',
'ENTER: b -> a',
'loop: a -> a',
'end: a -> c',
'LEAVE: a -> c',
]);
In terminal state nothing else can happen:
m.handleEvent({ type: 'go' });
m.handleEvent({ type: 'LEAVE' });
m.handleEvent({ type: 'ENTER' });
assert.equal(m.state, 'c');
assert.equal(m.count, 9);
state-machine Copyright 2020 © DEADB17 [email protected].
Distributed under the GNU LGPLv3.