@nicholaswmin/fsm v1.15.4
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`.
9 months ago
9 months ago
10 months ago
10 months ago
10 months ago
10 months ago
10 months ago
10 months ago
10 months ago
10 months ago
10 months ago
10 months ago
10 months ago
10 months ago
10 months ago
10 months ago
10 months ago
10 months ago
10 months ago
10 months ago
10 months ago
10 months ago
10 months ago
10 months ago
10 months ago
10 months ago
10 months ago
10 months ago
10 months ago
10 months ago
10 months ago
10 months ago
10 months ago
10 months ago
10 months ago
10 months ago
10 months ago
11 months ago
11 months ago
11 months ago
11 months ago
11 months ago
11 months ago
11 months ago
11 months ago
11 months ago
11 months ago
11 months ago
11 months ago
11 months ago
11 months ago
11 months ago
11 months ago
11 months ago
11 months ago
11 months ago
11 months ago
11 months ago
11 months ago
11 months ago
11 months ago
11 months ago
11 months ago
11 months ago
11 months ago
11 months ago
11 months ago
11 months ago
11 months ago
11 months ago
11 months ago
11 months ago
11 months ago
11 months ago
11 months ago
11 months ago
11 months ago
11 months ago
11 months ago
11 months ago
11 months ago
11 months ago
11 months ago
11 months ago
11 months ago
11 months ago
11 months ago
11 months ago
11 months ago
11 months ago
11 months ago
11 months ago
11 months ago
11 months ago
11 months ago
11 months ago
11 months ago
11 months ago
11 months ago
11 months ago
11 months ago
11 months ago
11 months ago
11 months ago
11 months ago
11 months ago
11 months ago
11 months ago
11 months ago
11 months ago
11 months ago
11 months ago
11 months ago
11 months ago
11 months ago
11 months ago
11 months ago
11 months ago
11 months ago
11 months ago
11 months ago
11 months ago
11 months ago
11 months ago
11 months ago
11 months ago
11 months ago
11 months ago
11 months ago
11 months ago
11 months ago