... 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
npm i @nicholaswmin/fsm
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.
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
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"
The fsm.state
property indicates the current state
:
const turnstile = fsm({
closed: { foo: 'opened' },
opened: { bar: 'closed' }
})
console.log(turnstile.state)
// "closed"
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)
.
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"
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"
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 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
.
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
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.
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
.
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
.
Revive an instance from it's JSON.
name | type | desc. | default |
---|---|---|---|
json |
string |
JSON.stringify(fsm) result |
required |
The current state
. Read-only.
name | type | default |
---|---|---|
state |
string |
current state |
unit tests:
node --run test
these tests require that certain coverage thresholds are met.
- 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.
The MIT License
Footnotes
-
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. ↩ -
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) => {}
, ternaryf = (a,b,c) => {}
, n-ary/variadic:f = (...args) => {}
↩ -
FSMs are rare but perfect candidates for inheritance because usually something
is-an
FSM.
However, Javascript doesn't support multiple inheritance so inheritingFSM
would create issues when inheriting other behaviours.Composition is also problematic since it namespaces the behaviour, causing it to lose it's expressiveness.
i.elight.fsm.turnOn
feels misplaced compared tolight.turnOn
. ↩