Détail du package

statebot

shuckster343MIT3.1.3

Describe the states and allowed transitions of a program using a flowchart-like syntax. Switch to states directly, or by wiring-up events. Statebot is an FSM.

finite automata, state machine, state chart, adjacency graph

readme

statebot 🤖

MIT license npm bundle size Version

Describe the states and allowed transitions of a program using a flowchart-like syntax. Switch to states directly, or by wiring-up events. Statebot is an FSM.

import { Statebot } from 'statebot'

const machine = Statebot('traffic-lights', {
  chart: `
    go ->
      prepare-to-stop ->
      stop

    // ...gotta keep that traffic flowing
    stop ->
      prepare-to-go ->
      go
  `
})

machine.performTransitions({
  'stop -> prepare-to-go -> go':   { on: 'timer' },
  'go -> prepare-to-stop -> stop': { on: 'timer' },
})

machine.onEvent('timer', () => {
  redrawTrafficLights()
})

function redrawTrafficLights() {
  machine.inState({
    'stop': () =>
      console.log('Draw red light'),

    'prepare-to-go': () =>
      console.log('Draw red + yellow lights'),

    'go': () =>
      console.log('Draw green light'),

    'prepare-to-stop': () =>
      console.log('Draw yellow light'),
  })
}

setInterval(machine.Emit('timer'), 2000)

Since v3.1.0, Mermaid state-diagram support:

import { Statebot, mermaid } from 'statebot'

const machine = Statebot('traffic-lights', {
  chart: mermaid`
    stateDiagram
    direction LR
      go --> prepareToStop
        prepareToStop --> stop

      %% ...gotta keep that traffic flowing
      stop --> prepareToGo
        prepareToGo --> go
  `
})

CodeSandbox of the above example.

It's less than 5K gzipped, runs in Node and the browser, and is a shell-script too.

There are Hooks for these frameworks, too:

There is a lot of prior-art out there, most notably XState by David Khourshid, but I hope Statebot can offer a small contribution in the field of writing code that is easier to understand six-months after it has been written.

Installation

npm i statebot
<script src="https://unpkg.com/statebot@3.1.3/dist/browser/statebot.min.js"></script>

Quick Start:

React example:

(You can play around with this in a CodeSandbox.)

import React, { useState, useEffect } from 'react'
import { Statebot } from 'statebot'

// Statebot is framework agnostic. To use it with React,
// you might use something like this 3-line Hook:
function useStatebot(bot) {
  const [state, setState] = useState(bot.currentState())
  useEffect(() => bot.onSwitched(setState), [bot])
  return state
}

const loader$bot = Statebot('loader', {
  chart: `
    idle ->
      loading -> (loaded | failed) ->
      idle
  `
})

loader$bot.performTransitions(({ Emit }) => ({
  'idle -> loading': {
    on: 'start-loading',
    then: () => setTimeout(Emit('success'), 1000)
  },
  'loading -> loaded': {
    on: 'success'
  },
  'loading -> failed': {
    on: 'error'
  }
}))

const { Enter, Emit, inState } = loader$bot

function LoadingButton() {
  const state = useStatebot(loader$bot)

  return (
    <button
      className={state}
      onClick={Emit('start-loading')}
      disabled={inState('loading')}
    >
      {inState({
        'idle': 'Load',
        'loading': 'Please wait...',
        'loaded': 'Done!',
      })}
      ({state})
    </button>
  )
}

Node.js example:

const { Statebot } = require('statebot')

// Describe states + transitions
const machine = Statebot('promise-like', {
  chart: `

    idle ->
      // This one behaves a bit like a Promise
      pending ->
        (resolved | rejected) ->
      done

  `,
  startIn: 'pending'
})

// Handle events...
machine.performTransitions({
  'pending -> resolved': {
    on: 'success'
  }
})

// ...and/or transitions
machine.onTransitions({
  'pending -> resolved | rejected': () => {
    console.log('Sweet!')
  }
})

machine.onExiting('pending', toState => {
  console.log(`Off we go to: ${toState}`)
})

machine.canTransitionTo('done')
// false

machine.statesAvailableFromHere()
// ["resolved", "rejected"]

machine.emit('success')
// "Off we go to: resolved"
// "Sweet!"

Events

Statebot creates state-machines from charts, and we can switch states on events using performTransitions:

machine.performTransitions({
  'pending -> resolved': {
    on: 'data-loaded'
  }
})

// ^ This API is designed to read like this:
//   machine, perform transition "pending to
//   resolved" on "data-loaded".

Let's do a little more:

machine.performTransitions({
  'pending -> rejected': {
    on: ['data-error', 'timeout'],
    then: () => {
      console.warn('Did something happen?')
    }
  },

// ^ We can run something after a transition
//   happens with "then". Notice this will
//   happen after the "data-error" OR
//   "timeout" events.

  'resolved | rejected -> done': {
    on: 'finished'
  }

// ^ We can configure lots of transitions inside
//   one `performTransitions`. Here's one that
//   will switch from "resolved to done" OR
//   "rejected to done" when the "finished"
//   event is emitted.

})

// In this API, when events are emitted they
// can pass arguments to the "then" method.

// See the section below on "Passing data around".

We can also do stuff when states switch with onTransitions:

machine.onTransitions({
  'pending -> resolved': function () {
    console.log('Everything went lovely...')
    machine.enter('done')
  },

  'pending -> rejected': function () {
    console.warn('That did not go so well...')
    machine.enter('done')
  },

  'resolved | rejected -> done': function () {
    console.log('All finished')
  }
})

Let's do a little more:

machine.onTransitions(({ emit, Emit }) => ({
  'idle -> pending': function () {

// ^ This API is designed to read like this:
//   machine, on transition "idle to pending",
//   run a callback.

    getSomeData().then(
      (...args) => emit('data-loaded', ...args)
    )

// ^ emit() or Emit()? Which one to use? Maybe
//   you can infer the different meanings from
//   the .catch() of this Promise:

    .catch(Emit('data-error'))

// ^ Got it? Emit() is shorthand for:
//     (...args) => emit('event', ...args)
//
//   So emit() fires immediately, and Emit()
//   generates an emitter-method.

  }
}))

// In this API, the state-switching functions
// enter() and Enter() can pass arguments to
// these callbacks.

// See the section below on "Passing data around".

Both performTransitions and onTransitions take objects or functions that return objects in order to configure them.

Object:

machine.onTransitions({
  'idle -> pending': // etc...

Function:

machine.onTransitions(({ emit, enter, Emit, Enter }) => ({
  'idle -> pending': // etc...

In the case of a function, a single argument is passed-in: An object containing helpers for emitting events and entering states. In the above example we're pulling-in the helpers emit and enter, and also their corresponding factories: Emit and Enter.

Of course, you don't have to use an "implicit return":

machine.onTransitions(({ emit, Emit, enter, Enter }) => {
  // Setup, closure gubbins and so on...

  return {
    'idle -> pending': // etc...
  }
})

performTransitions hitches onto events, and onTransitions hitches onto state-transitions.

A Statebot FSM can have as many hitchers as you like, or none at all.

In any case, once an FSM is configured we are sometimes only interested in the state we are currently in, about to exit, or about to enter. There are hitchers for those, too:

machine.onExiting('pending', toState => {
  console.log('we are heading to:', toState)
})

machine.onEntered('done', fromState => {
  console.log('we came from:', fromState)
})

machine.currentState()
machine.previousState()

You can use the following snippet to tinker with the examples above:

function getSomeData() {
  return new Promise(
    (resolve, reject) => {
      setTimeout(resolve, 1000)

      // Randomly reject
      setTimeout(reject,
        500 + Math.round(Math.random() * 750)
      )
    }
  )
}

// Randomly timeout
setTimeout(() => machine.emit('timeout', true),
  750 + Math.round(Math.random() * 750)
)

machine.enter('pending')

Passing data around

Events can pass data to callbacks using emit:

machine.performTransitions({
  'pending -> resolved': {
    on: ['data-loaded'], // event name(s)
    then: (...args) => {
      console.log('Received:', args)
    }
  }
})

machine.emit('data-loaded', 1, 2, 3)

// Console output:
// > Received: [1, 2, 3]

The state-switching method enter can pass data, too:

machine.onTransitions({
  'idle -> pending': (...args) => {
    console.log('onTransitions:', args)
  }
})

machine.onEntering('pending',
  (fromState, ...args) => {
    console.log('onEntering:', args)
  }
)

machine.onExited('pending',
  (toState, ...args) => {
    console.log('onExited:', args)
  }
)

machine.enter('pending', 'a', 'b', 'c')
machine.enter('resolved', 3, 2, 1)

// Console output:
// > onEntering: ["a", "b", "c"]
// > onTransitions: ["a", "b", "c"]
// > onExited: [3, 2, 1]

Logging

A Statebot FSM is a pretty noisy thing by default.

You can tone-it down using the logLevel argument in its options:

const machine = Statebot('example', {
  // ...
  logLevel: 2 // Everything except console.info()
})
  • A zero 0 here means silence
  • One 1 prints console.warn()'s
  • Two 2 prints warnings, plus console.log() + console.table()
  • Three 3 prints all the above, plus console.info()

3 is the default. Argument type-errors will always throw.

Testing

assertRoute can be used to test if an FSM traced a particular route:

const { assertRoute } = require('statebot/assert')

assertRoute(
  machine, 'pending -> resolved -> done',
  {
    description: 'Data loaded with no issues',
    fromState: 'idle',
    timeoutInMs: 1000 * 20,
    permittedDeviations: 0
  }
)
.then(() => console.log('Assertion passed!'))
.catch(err => console.error(`Hot fudge: ${err}`))

machine.enter('idle')

As you can see, it returns a Promise that you can use with an assertion-library.

The method itself produces output using console.table:

[example] aId<1>: Data loaded with no issues: [FAILED]
┌─────────┬────────────┬────────────┬───────────────────────┬──────────────────┐
│ (index) │   states   │  expected  │         info          │       took       │
├─────────┼────────────┼────────────┼───────────────────────┼──────────────────┤
│    0    │  'pending''pending''OKAY               ''          2 ms' │
│    1    │ 'rejected''resolved''WRONG STATE        ''       0.73 s ' │
│    2    │ 'rejected''resolved''TOO MANY DEVIATIONS''              ' │
│    3    │ 'rejected''(done)''TOO MANY DEVIATIONS''              ' │
│    4    │     '''''                   ''TOTAL: 0.74 s ' │
└─────────┴────────────┴────────────┴───────────────────────┴──────────────────┘

aId<1> means assertRoute has run once so far.

You can also check if a certain route can be followed with routeIsPossible:

const { routeIsPossible } = require('statebot/assert')

routeIsPossible(machine, 'pending -> resolved -> pending')
// false

Chart Syntax

Statebot charts are just strings, or arrays of strings:

var oneLiner = '-> idle -> done'
var multiLiner = [
  '-> idle',
  'idle -> done'
]

We can use Template Literals to make more readable charts in modern JavaScript:

const chart = `
  -> idle
  idle -> done
`

Charts list all states and the allowed transitions between them using ->.

const fsm = Statebot('just a name', {
  chart: `

    -> idle
    idle -> done

  `,
  startIn: 'idle'
})

Careful now: When startIn is absent the default state is inferred from the first state seen in the chart. Also, empty-strings are valid states: The first-line of this chart allows transitioning from '' to 'idle', and the empty-string would be the starting-state if startIn were omitted.

The pipe-character | means OR:

This...

  pending -> resolved | rejected
  resolved | rejected -> done

...is shorthand for this...

  pending -> resolved
  pending -> rejected
  resolved -> done
  rejected -> done

...which is also equivalent to:

  pending -> (resolved | rejected) -> done

Notice the use of parentheses (). These are completely ignored by the parser and just provide syntactic sugar.

Any lines ending with -> or | are considered to be part of the next line.

This...

  pending ->
    resolved |
      rejected

...is the same as this:

  pending -> resolved | rejected

Comments are allowed:

  // These comments
  pending ->
    resolved |  // will be
      rejected  // ignored

Indentation has no meaning.

Examples of real charts

Here are some charts I've used with Statebot:

Web-server:

  // Email config, static files
  -> booting ->
    email-config -> image-sizes -> templates -> pages

  // If pages are ready, start webserver
  pages -> webserver

  // Problem...?
        booting |
   email-config |
    image-sizes |
      templates |
          pages -> unrecoverable

  // A watchdog will restart the web-server
  unrecoverable ->  report-and-quit

Email sender:

  idle -> send

  // Let's wait a few seconds before committing...
  (send | update) ->
    debounce -> sending |
      (update | cancel)

  sending -> (sent | failed)

  // All done!
  (sent | cancel | failed) -> done

Drag-and-drop:

  idle ->
    drag-detect ->
      (dragging | clicked)

  // Click detected!
  clicked -> idle

  // Drag detected!
  dragging ->
    drag-wait -> dragged -> drag-wait

  // Drag finished...
  (drag-wait | dragged) ->
    (drag-done | drag-cancel) ->
      idle

The documentation has a few more examples.

Why?

"The initial mystery that attends any journey is: How did the traveller reach his starting-point in the first place?" -Louise Bogan

I wrote Statebot to learn about FSMs, but really I ended up learning more about writing programs in general, and what I do and do not like about the process.

Above all, state-machines are extremely useful when it comes to reading old code. Their innate robustness and predictability are an added bonus.

Understanding the flow of a program I haven't come back to in a while is exactly what I enjoy the most about using state-machines.

With Statebot itself, code can be marshalled into a shape that "fans-out":

  1. The compact chart at the top describes the flow.

  2. The hitchers reveal how transitions happen.

  3. Finally, the callbacks pull-in all the business-logic. (This might still be a huge jumbled mess of course, but at least at this point I'll have a few leads into what's supposed to be going on!)

Frankly, this does add a bit of redundancy when using Statebot.

Transitions are repeated between charts and hitchers, and there can be a bit of to-ing and fro-ing to get them right. But for me, the pay-off of being able to jump-in to an old piece of code and grok it quickly is worth it.

The bottom-line with any tool is to use it sparingly and appropriately, and the same applies with Statebot.

Contributing

I consider the API stable and would not like it to change much. I don't want it to become a store or data-manager. Many APIs exist that are dedicated to such tasks. I'd really like to keep Statebot lean.

Of course, bug-fixes, forks, and integrations are very welcome! If you feel it has saved you a little pain while trying to grok your own old projects, please consider buying me a coffee. :)

Credits

With thanks to \@szabeszg for the suggestion and discussion around:

  • nextState = peek(eventName)
  • canTransitionTo(state, { afterEmitting: event })

    🙏

Statebot was inspired by a trawl through Wikipedia and Google, which in turn was inspired by XState by David Khourshid. You should check it out.

Statebot integrates events for the browser-build.

  • Since Statebot 2.5.0 mitt is also compatible.
  • Since Statebot 2.6.0 mitt is used internally.

The Statebot logo uses the "You're Gone" font from Typodermic Fonts. The logo was made with Acorn. The documentation is written in JSDoc and is built with Typedoc.

Statebot was written by Conan Theobald.

License

Statebot is MIT licensed.

changelog

Changelog

All notable changes to this project will be documented in this file.

The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.

[Unreleased]

[3.1.3] - 2023-04-13

Fixed

  • Oh dear: require() should be import

[3.1.2] - 2023-04-13

Fixed

  • Bad require() path

[3.1.1] - 2023-04-13

Updated

  • README tweaks

[3.1.0] - 2023-04-13

Added

  • mermaid helper function to allow Mermaid state-diagrams to be used with Statebot:
import { Statebot, mermaid } from 'statebot'

const fsm = Statebot('traffic-lights', {
  chart: mermaid`
    stateDiagram
    direction LR
      go --> prepareToStop
        prepareToStop --> stop

      %% ...gotta keep that traffic flowing
      stop --> prepareToGo
        prepareToGo --> go
  `
})

Front-matter is ignored:

import { Statebot, mermaid } from 'statebot'

const fsm = Statebot('traffic-lights', {
  chart: mermaid`
    ---
    title: Traffic lights
    ---
    stateDiagram
    direction LR
      go --> prepareToStop
        prepareToStop --> stop

      %% ...gotta keep that traffic flowing
      stop --> prepareToGo
        prepareToGo --> go
  `
})

::: blocks are also ignored, which is useful because the Mermaid Preview extension for VS Code will display a chart when the cursor is placed between the blocks:

import { Statebot, mermaid as mmd } from 'statebot'

const fsm = Statebot('traffic-lights', {
  chart: mmd`
    ::: mermaid
    stateDiagram
    direction LR
      go --> prepareToStop
        prepareToStop --> stop

      %% ...gotta keep that traffic flowing
      stop --> prepareToGo
        prepareToGo --> go
    :::
  `
})

Note: Mermaid start [*] --> and stop --> [*] states will become __START__ and __STOP__ Statebot states respectively.

[3.0.7] - 2023-02-03

Fixed

  • package.json .mjs exports for Hooks

[3.0.6] - 2023-02-03

Changed

  • Most .js extensions are now .mjs. Hopefully this fixes compatibility with newer Jest versions

[3.0.5] - 2022-07-09

Updated

  • README tweak

[3.0.4] - 2022-07-09

Fixed

  • Typedefs for TStatebotFsm should be methods rather than props

[3.0.3] - 2022-07-07

Updated

  • Exclude mitt from cjs/esm build: It's specified as a regular dependency now
  • Remove .dev from build filenames
  • Tweak README as JSDoc lives in index.d.ts now

[3.0.2] - 2022-07-07

Fixed

  • Out of date types locations in package.json

[3.0.1] - 2022-07-07

Updated

  • Links to hooks

[3.0.0] - 2022-07-07

BREAKING CHANGES

Imports updated:

  • routeIsPossible / assertRoute now come from 'statebot/assert'
  • React/Mithril Hooks can be imported from 'statebot/hooks/react' and 'statebot/hooks/mithril'. There are no longer separate packages for these.

Updated

  • Documentation now built with Typedoc
  • Typedefs are now manually updated instead of generated from jsdoc

[2.9.3] - 2022-01-11

Fixed

  • Fix esbuild error by re-ordering package.json exports

[2.9.2] - 2021-12-20

Fixed

  • Fix WebPack error: "Default condition should be last one"

[2.9.1] - 2021-12-18

Fixed

  • Replace nullish coalescing ?? with ternary to fix Bundlephobia error.

[2.9.0] - 2021-12-18

Added

  • peek(eventName, stateObject?), tests, documentation
  • canTransitionTo('state', { afterEvent: 'event' }), tests, documentation

Updated

  • Updated dependencies

[2.8.1] - 2021-09-04

Updated

  • Updated dependencies, tweaked README

[2.8.0] - 2021-06-07

Added

  • If a performTransition then method or onTransitions callback return a function, it will be invoked when the state is exited in the same manner as if an .onExiting() handler was created using it.

[2.7.4] - 2021-04-25

Fixed

  • Guard against wrapped-on() args not being an array

[2.7.3] - 2021-04-25

Updated

  • Dependencies, package.json tweaks

[2.7.2] - 2021-01-22

Fixed

  • Wrong package.json setting for Node

[2.7.1] - 2021-01-17

Fixed

  • Updated README example required commas

[2.7.0] - 2021-01-17

Added

  • inState/InState now supports an object for config as well as a string:
inState('idle')
// true | false

inState('idle', 'waiting')
// "waiting" | null

inState({
  idle: 'hold up',
  success: () => 'fn-result',
  done: <JSX />
})
// "hold up" | "fn-result" | <JSX /> | null

[2.6.3] - 2021-01-13

Added

  • package.json exports

[2.6.2] - 2021-01-04

Fixed

  • Revert previous argument-defaults tweak, as it bugged Enter(). Fixed, regression test added

Added

  • Further tests for arity of emit/Emit

Changed

  • Replace padLeft/padRight with padEnd/padStart respectively

[2.6.1] - 2021-01-02

Updated

  • Add code-comments to make CodeFactor happy with documentation page
  • Remove argument-defaults to reduce compiled/minified code-size slightly

[2.6.0] - 2020-12-30

Changed

  • Now using Mitt for events
  • Changed license from ISC to MIT

[2.5.1] - 2020-08-11

Fixed

  • Custom event-emitter support broken in previous commit (emit not working)

[2.5.0] - 2020-08-10

Updated

  • Dependencies
  • Throws if invalid event-emitter passed-in

Added

  • Compatibility with mitt event-emitter library
  • inState + statesAvailableFromHere tests
  • More links for pause/resume/paused in docs
  • Build-comments for CodeFactor

[2.4.0] - 2020-07-23

Updated

  • Dependencies

Added

  • pause/paused/resume methods, tests, docs

[2.3.10] - 2020-07-15

Updated

  • Put an example at the top of the README to get to the point more quickly :P

[2.3.9] - 2020-07-14

Fixed

  • routeIsPossible() did not support "backtracking" in some cases

Added

  • Basic tests for backtracking

[2.3.8] - 2020-07-11

Fixed

  • Charts with empty-strings for states were not always parsing properly

Added

  • Added tests for charts with empty-strings
  • Added tests for callback-counts + ordering
  • Tweak Hook-examples in the README

[2.3.7] - 2020-07-06

Fixed

  • .DS_Store snuck into dist/
  • Build index.d.ts automatically, fixing broken autocompletion
  • ESM build renamed from .mjs to .js, since tsc won't read it to build index.d.ts otherwise

[2.3.6] - 2020-07-06

Changed

  • Use ES6 import/export syntax in source-files (slightly small dist/ files resulted)
  • Put dev source-maps in their own files

Added

  • Build ES6 module in dist/esm
  • Got started with some basic tests
  • React Hooks :) and Mithril ones, too

[2.3.5] - 2020-06-22

Fixed

  • Fix require().default regression in Rollup config

[2.3.4] - 2020-06-22

Added

  • Build documentation.js JSON in docs/ for tinkering

Fixed

  • Compatibility with projects using Rollup

Updated

  • Emphasise in docs that Statebot isn't bound to a particular framework
  • Updated babel, documentation, eslint, rollup

[2.3.3] - 2020-06-14

Added

  • Include a React example in docs + README

[2.3.2] - 2020-05-29

Fixed

  • Typo in code

Changed

  • A few more README tweaks

[2.3.1] - 2020-05-27

Fixed

  • Fix docs for updated Enter/Emit

[2.3.0] - 2020-05-27

Added

  • Enter/Emit can accept arguments that will curry into the functions they return.
  • inState now fully documented.
  • InState supports currying arguments into outputWhenTrue() argument.

[2.2.1] - 2020-05-26

Fixed

  • A few JSDoc links weren't working
  • VS Code autocomplete wasn't working fully

[2.2.0] - 2020-05-25

Changed

  • Migrated from Webpack to Rollup; smaller min builds, UMD build
  • Add "files" to package.json to reduce npm module size
  • Reduce code-size a little

[2.1.1] - 2020-05-23

Changed

  • README tweaks
  • Upgrade dev-dependencies

[2.1.0] - 2020-04-24

Fixed

  • Browser build now uses 'var' webpack option rather than 'umd'

Changed

  • Lua inspired 'coroutine' chart in the JSDocs

[2.0.0] - 2020-04-20

Changed

  • Updated disallowed characters for cross-env compatibility of charts.

    This was a tiny change to Statebot, but will break any charts using the characters now excluded (probably none at this point!) Still, it's good to show semver willing, eh? ;)

Added

  • Include links to the shell-port, Statebot-sh

[1.0.6] - 2020-04-13

Fixed

  • Various post-publishing documentation fixes

[1.0.0] - 2020-04-13

Added

  • Statebot :)