@nicholaswmin/fsm
v1.15.4
Published
a finite-state machine
Downloads
191
Maintainers
Readme
fsm
... is an abstract machine that can be in one of a finite number of states.
The change from onestate
to another is called atransition
.
This package constructs simple FSM's which express their logic declaratively & safely.[^1]
~1KB
, zero dependencies, opinionated
Basic
Extras
API
Meta
Install
npm i @nicholaswmin/fsm
Example
A turnstile gate that opens with a coin.
When opened you can push through it; after which it closes again:
import { fsm } from '@nicholaswmin/fsm'
// define states & transitions:
const turnstile = fsm({
closed: { coin: 'opened' },
opened: { push: 'closed' }
})
// transition: coin
turnstile.coin()
// state: opened
// transition: push
turnstile.push()
// state: closed
console.log(turnstile.state)
// "closed"
Each step is broken down below.
Initialisation
An FSM with 2 possible states
, each listing a single transition
:
const turnstile = fsm({
closed: { coin: 'opened' },
opened: { push: 'closed' }
})
state: closed
: allowstransition: coin
which sets:state: opened
state: opened
: allowstransition: push
which sets:state: closed
Transition
A transition
can be called as a method:
const turnstile = fsm({
// defined 'coin' transition
closed: { coin: 'opened' },
// defined 'push' transition
opened: { push: 'closed' }
})
turnstile.coin()
// state: opened
turnstile.push()
// state: closed
The current state
must list the transition, otherwise an Error
is thrown:
const turnstile = fsm({
closed: { coin: 'opened' },
opened: { push: 'closed' }
})
turnstile.push()
// TransitionError:
// current state: "closed" has no transition: "push"
Current state
The fsm.state
property indicates the current state
:
const turnstile = fsm({
closed: { foo: 'opened' },
opened: { bar: 'closed' }
})
console.log(turnstile.state)
// "closed"
Hook methods
Hooks are optional methods, called at specific transition phases.
They must be set as hooks
methods; an Object
passed as 2nd argument of
fsm(states, hooks)
.
Transition hooks
Called before the state is changed & can optionally cancel a transition.
Must be named: on<transition-name>
, where <transition-name>
is an actual
transition
name.
const turnstile = fsm({
closed: { coin: 'opened' },
opened: { push: 'closed' }
}, {
onCoin: function() {
console.log('got a coin')
},
onPush: function() {
console.log('got pushed')
}
})
turnstile.coin()
// "got a coin"
turnstile.push()
// "got pushed"
State hooks
Called after the state is changed.
Must be named: on<state-name>
, where <state-name>
is an actual state
name.
const turnstile = fsm({
closed: { coin: 'opened' },
opened: { push: 'closed' }
}, {
onOpened: function() {
console.log('its open')
},
onClosed: function() {
console.log('its closed')
}
})
turnstile.coin()
// "its open"
turnstile.push()
// "its closed"
Hook arguments
Transition methods can pass arguments to relevant hooks, assumed to be variadic: [^2]
const turnstile = fsm({
closed: { coin: 'opened' },
opened: { push: 'closed' }
}, {
onCoin(one, two) {
return console.log(one, two)
}
})
turnstile.coin('foo', 'bar')
// foo, bar
Transition cancellations
Transition hooks can cancel the transition by returning
false
.
Cancelled transitions don't change the state nor call any state hooks.
example: cancel transition to
state: opened
if the coin is less than50c
const turnstile = fsm({
closed: { coin: 'opened' },
opened: { push: 'closed' }
}, {
onCoin(coin) {
return coin >= 50
}
})
turnstile.coin(30)
// state: closed
// state still "closed",
// add more money?
turnstile.coin(50)
// state: opened
note: must explicitly return
false
, not justfalsy
.
Asynchronous transitions
Mark relevant hooks as async
and await
the transition:
const turnstile = fsm({
closed: { coin: 'opened' },
opened: { push: 'closed' }
}, {
async onCoin(coins) {
// simulate something async
await new Promise(res => setTimeout(res.bind(null, true), 2000))
}
})
await turnstile.coin()
// 2 seconds pass ...
// state: opened
Serialising to JSON
Simply use JSON.stringify
:
const hooks = {
onCoin() { console.log('got a coin') }
onPush() { console.log('pushed ...') }
}
const turnstile = fsm({
closed: { coin: 'opened' },
opened: { push: 'closed' }
}, hooks)
turnstile.coin()
// got a coin
const json = JSON.stringify(turnstile)
... then revive with:
const revived = fsm(json, hooks)
// state: opened
revived.push()
// pushed ..
// state: closed
note:
hooks
are not serialised so they must be passed again when reviving, as shown above.
FSM as a mixin
Passing an Object
as hooks
to: fsm(states, hooks)
assigns FSM behaviour
on the provided object.
Useful in cases where an object must function as an FSM, in addition to some other behaviour.[^3]
example: A
Turnstile
functioning as both anEventEmitter
& anFSM
class Turnstile extends EventEmitter {
constructor() {
super()
fsm({
closed: { coin: 'opened' },
opened: { push: 'closed' }
}, this)
}
}
const turnstile = new Turnstile()
// works as EventEmitter.
turnstile.emit('foo')
// works as an FSM as well.
turnstile.coin()
// state: opened
this concept is similar to a
mixin
.
API
fsm(states, hooks)
Construct an FSM
| name | type | desc. | default |
|----------|----------|---------------------------------|----------|
| states
| object
| a state-transition table | required |
| hooks
| object
| implements transition hooks | this
|
states
must have the following abstract shape:
state: {
transition: 'next-state',
transition: 'next-state'
},
state: { transition: 'next-state' }
- The 1st state in
states
is set as the initial state. - Each
state
can list zero, one or many transitions. - The
next-state
must exist as astate
.
fsm(json, hooks)
Revive an instance from it's JSON.
Arguments
| name | type | desc. | default |
|----------|----------|-------------------------------|----------|
| json
| string
| JSON.stringify(fsm)
result | required |
fsm.state
The current state
. Read-only.
| name | type | default |
|----------|----------|---------------|
| state
| string
| current state |
Tests
unit tests:
node --run test
these tests require that certain coverage thresholds are met.
Contributing
Publishing
- collect all changes in a pull-request
- merge to
main
when all ok
then from a clean main
:
# list current releases
gh release list
Choose the next Semver, i.e: 1.3.1
, then:
gh release create 1.3.1
note: dont prefix releases/tags with
v
, justx.x.x
is enough.
The Github release triggers the npm:publish workflow
,
publishing the new version to npm.
It then attaches a Build Provenance statement on the Release Notes.
That's all.
Authors
License
The MIT License
Footnotes
[^1]: A finite-state machine can only exist in one and always-valid state.
It requires declaring all possible states & the rules under which it can
transition from one state to another.
[^2]: A function that accepts an infinite number of arguments.
Also called: functions of "n-arity" where "arity" = number of arguments.
i.e: nullary: `f = () => {}`, unary: `f = x => {}`,
binary: `f = (x, y) => {}`, ternary `f = (a,b,c) => {}`,
n-ary/variadic: `f = (...args) => {}`
[^3]: FSMs are rare but perfect candidates for inheritance because usually
something is-an
FSM.
However, Javascript doesn't support multiple inheritance so inheriting
FSM
would create issues when inheriting other behaviours.
*Composition* is also problematic since it namespaces the behaviour,
causing it to lose it's expressiveness.
i.e `light.fsm.turnOn` feels misplaced compared to `light.turnOn`.