Skip to content

nicholaswmin/fsm

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

19 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

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

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

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

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

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.