1.15.4 • Published 1 year ago

@nicholaswmin/fsm v1.15.4

Weekly downloads
-
License
MIT
Repository
github
Last release
1 year ago

tests ccovt

fsm

A finite-state machine

... is an abstract machine that can be in one of a finite number of states.
The change from one state to another is called a transition.

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: allows transition: coin which sets: state: opened
  • state: opened: allows transition: 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 than 50c

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 just falsy.

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 an EventEmitter & an FSM

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

nametypedesc.default
statesobjecta state-transition tablerequired
hooksobjectimplements transition hooksthis

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 a state.

fsm(json, hooks)

Revive an instance from it's JSON.

Arguments

nametypedesc.default
jsonstringJSON.stringify(fsm) resultrequired

fsm.state

The current state. Read-only.

nametypedefault
statestringcurrent state

Tests

unit tests:

node --run test

these tests require that certain coverage thresholds are met.

Contributing

Contribution Guide

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, just x.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

N.Kyriakides; @nicholaswmin

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`.
  
1.15.4

1 year ago

1.14.2

1 year ago

1.14.1

1 year ago

6.6.4

1 year ago

1.12.7

1 year ago

1.12.9

1 year ago

1.13.2

1 year ago

1.13.1

1 year ago

1.13.6

1 year ago

1.13.5

1 year ago

1.13.4

1 year ago

1.13.3

1 year ago

1.13.8

1 year ago

1.10.5

1 year ago

1.10.4

1 year ago

1.10.3

1 year ago

1.10.9

1 year ago

1.10.8

1 year ago

1.10.7

1 year ago

1.10.6

1 year ago

1.11.4

1 year ago

1.11.3

1 year ago

1.11.2

1 year ago

1.11.1

1 year ago

1.11.6

1 year ago

1.11.5

1 year ago

1.9.9

1 year ago

1.9.1

1 year ago

1.9.0

1 year ago

1.9.8

1 year ago

1.9.7

1 year ago

1.9.6

1 year ago

1.9.5

1 year ago

1.9.4

1 year ago

1.9.3

1 year ago

1.8.9

1 year ago

1.8.8

1 year ago

1.8.2

1 year ago

1.8.1

1 year ago

1.8.7

1 year ago

1.8.6

1 year ago

1.8.5

1 year ago

1.8.4

1 year ago

1.8.3

1 year ago

1.6.4

1 year ago

1.4.6

1 year ago

1.6.3

1 year ago

1.4.5

1 year ago

1.8.0

1 year ago

1.6.2

1 year ago

1.4.4

1 year ago

1.6.1

1 year ago

1.4.3

1 year ago

1.4.2

1 year ago

1.4.1

1 year ago

1.4.0

1 year ago

1.4.21

1 year ago

1.7.9

1 year ago

1.7.8

1 year ago

1.7.7

1 year ago

1.5.9

1 year ago

1.5.8

1 year ago

1.7.5

1 year ago

1.5.7

1 year ago

1.7.4

1 year ago

1.5.6

1 year ago

1.7.3

1 year ago

1.5.5

1 year ago

1.7.2

1 year ago

1.5.4

1 year ago

1.7.1

1 year ago

1.5.3

1 year ago

1.7.0

1 year ago

1.5.2

1 year ago

1.5.1

1 year ago

1.7.31

1 year ago

1.7.41

1 year ago

1.7.42

1 year ago

1.7.43

1 year ago

1.7.44

1 year ago

1.6.8

1 year ago

1.7.45

1 year ago

1.6.7

1 year ago

1.4.9

1 year ago

1.7.46

1 year ago

1.6.6

1 year ago

1.7.47

1 year ago

1.6.5

1 year ago

1.4.7

1 year ago

1.7.48

1 year ago

1.7.49

1 year ago

1.3.3

1 year ago

1.2.8

1 year ago

1.2.7

1 year ago

1.2.6

1 year ago

1.2.4

1 year ago

1.3.2

1 year ago

1.2.3

1 year ago

1.2.2

1 year ago

1.3.0

1 year ago

1.2.9

1 year ago

1.2.1

1 year ago

1.2.0

1 year ago

1.1.9

1 year ago

1.1.8

1 year ago

1.1.6

1 year ago

1.1.54

1 year ago

1.1.53

1 year ago

1.1.52

1 year ago

1.1.51

1 year ago

1.1.50

1 year ago

1.1.49

1 year ago

1.1.48

1 year ago

1.1.47

1 year ago

1.1.46

1 year ago

1.1.45

1 year ago

1.1.44

1 year ago

1.1.43

1 year ago

1.1.42

1 year ago

1.1.41

1 year ago

1.1.40

1 year ago

1.1.39

1 year ago

1.1.38

1 year ago

1.1.37

1 year ago

1.1.36

1 year ago

1.1.35

1 year ago

1.1.33

1 year ago

1.1.32

1 year ago

1.1.31

1 year ago

1.1.3

1 year ago

1.0.0

1 year ago