An open API service indexing awesome lists of open source software.

https://github.com/nicholaswmin/fsm

stupid simple, finite-state machine
https://github.com/nicholaswmin/fsm

automata-theory declarative-programming finite-state-machine

Last synced: 3 months ago
JSON representation

stupid simple, finite-state machine

Awesome Lists containing this project

README

          

[![tests][testb]][tests] [![ccovt][cocov]](#tests)

# fsm

> A [finite-state machine][fsm]
>
> ... 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].
We use this internally on production.

`~1KB`, zero dependencies, [opinionated][dgoals], and tested throught the nose;
it eschews fancy features for robustness, ease of use and zero-maintenace
even across major Node.js updates.

### Basic

- [Install](#install)
- [Example](#example)
- [Initialisation](#initialisation)
- [Transition](#transition)
- [Current state](#current-state)

### Extras

- [Hooks](#hook-methods)
- [Transition cancellations](#transition-cancellations)
- [Asynchronous transitions](#asynchronous-transitions)
- [Serialising to JSON](#serialising-to-json)
- [As a mixin](#fsm-as-a-mixin)

### API

- [`fsm(states, hooks)`](#fsmstates-hooks)
- [`fsm(json, hooks)`](#fsmjson-hooks)
- [`fsm.state`](#fsmstate)

### Meta

- [Tests](#tests)
- [Publishing](#publishing)
- [Authors](#authors)
- [License](#license)

## Install

```bash
npm i @nicholaswmin/fsm
```

## Example

> A [turnstile][turn] gate that opens with a coin.
> When opened you can push through it; after which it closes again:

```js
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`:

```js
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:

```js
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:

```js
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`:

```js
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](#transition-cancellations).

Must be named: `on`, where `` is an actual
`transition` name.

```js
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`, where `` is an actual `state` name.

```js
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]

```js
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](#transition-hooks) can cancel the transition by returning
`false`.

Cancelled transitions don't change the *state* nor call any
[state hooks](#state-hooks).

> example: cancel transition to `state: opened` if the coin is less than `50c`

```js
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`][falsy].

## Asynchronous transitions

Mark relevant hooks as [`async`][async] and [`await`][await] the transition:

```js
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`][JSON.stringify]:

```js
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:

```js
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`][ee] & an `FSM`

```js
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`][mixin].

## API

### `fsm(states, hooks)`

Construct an `FSM`

| name | type | desc. | default |
|----------|----------|---------------------------------|----------|
| `states` | `object` | a [state-transition table][stt] | required |
| `hooks` | `object` | implements transition hooks | `this` |

`states` must have the following abstract shape:

```js
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][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:

```bash
node --run test
```

> these tests *require* that certain [coverage thresholds][ccov-thresh] are met.

## Contributing

[Contribution Guide][contr-guide]

## Publishing

- collect all changes in a pull-request
- merge to `main` when all ok

then from a clean `main`:

```bash
# list current releases
gh release list
```

Choose the next [Semver][semver], i.e: `1.3.1`, then:

```bash
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`][npmpubworkflow],
publishing the new version to [npm][npmproj].

It then attaches a [Build Provenance][provenance] statement on the
[Release Notes][rel-notes].

That's all.

## Authors

[N.Kyriakides; @nicholaswmin][author]

## License

The [MIT License][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`.

[testb]: https://github.com/nicholaswmin/fsm/actions/workflows/tests:unit.yml/badge.svg
[tests]: https://github.com/nicholaswmin/fsm/actions/workflows/tests:unit.yml
[cocov]: https://img.shields.io/badge/coverage-%3E%2095%25-blue
[ccovt]: https://github.com/nicholaswmin/fsm/blob/6db5c1c5ede6edb15cb1b8431a4716163a7410d4/package.json#L11

[turn]: https://en.wikipedia.org/wiki/Finite-state_machine#Example:_coin-operated_turnstile
[fsm]: https://en.wikipedia.org/wiki/Finite-state_machine
[stt]: https://en.wikipedia.org/wiki/State-transition_table
[dfsm]: https://en.wikipedia.org/wiki/Deterministic_finite_automaton
[automata]: https://en.wikipedia.org/wiki/Automata_theory
[mixin]: https://developer.mozilla.org/en-US/docs/Glossary/Mixin
[alternatives]: https://www.npmjs.com/search?q=fsm
[async]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/async_function
[await]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/await
[promise]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise
[JSON.stringify]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify
[json]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON
[mixin]: https://developer.mozilla.org/en-US/docs/Glossary/Mixin
[falsy]: https://developer.mozilla.org/en-US/docs/Glossary/Falsy
[ee]: https://nodejs.org/docs/latest/api/events.html#class-eventemitter

[npmproj]: https://www.npmjs.com/package/@nicholaswmin/fsm
[semver]: https://semver.org/
[npmpubworkflow]: https://github.com/nicholaswmin/fsm/actions/workflows/npm:publish.yml
[provenance]: https://docs.npmjs.com/generating-provenance-statements/
[rel-notes]: https://github.com/nicholaswmin/fsm/releases/latest

[prov]: https://search.sigstore.dev/?logIndex=136020643
[contr-guide]: https://github.com/nicholaswmin/fsm/blob/main/.github/CONTRIBUTING.md
[ccov-thresh]: https://github.com/nicholaswmin/fsm/blob/main/package.json#L11
[dgoals]: https://github.com/nicholaswmin/fsm/blob/main/.github/CONTRIBUTING.md#design-goals
[author]: https://github.com/nicholaswmin
[license]: https://raw.githubusercontent.com/nicholaswmin/fsm/refs/heads/main/LICENSE