https://github.com/nicholaswmin/fsm
stupid simple, finite-state machine
https://github.com/nicholaswmin/fsm
automata-theory declarative-programming finite-state-machine
Last synced: 16 days ago
JSON representation
stupid simple, finite-state machine
- Host: GitHub
- URL: https://github.com/nicholaswmin/fsm
- Owner: nicholaswmin
- License: mit
- Created: 2024-10-15T22:46:11.000Z (about 1 year ago)
- Default Branch: main
- Last Pushed: 2025-01-06T01:57:01.000Z (10 months ago)
- Last Synced: 2025-07-20T00:16:49.226Z (4 months ago)
- Topics: automata-theory, declarative-programming, finite-state-machine
- Language: JavaScript
- Homepage: https://nicholaswmin.github.io/fsm/
- Size: 59.6 KB
- Stars: 0
- Watchers: 1
- Forks: 0
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
- Contributing: .github/CONTRIBUTING.md
- License: LICENSE
- Security: .github/SECURITY.md
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