1.3.1 • Published 6 years ago

gen-statem v1.3.1

Weekly downloads
7
License
MIT
Repository
github
Last release
6 years ago

StateM

Introduction

StateM is framework for Javascript state machines.

StateM is largely based on Erlang OTP’s gen_statem behavior.

npm.io

State machines are ideal for programming tasks that involve a) a sequence of operations and b) logic that dictates which operation will be executed after the current one based, for example, on:

  • data you’re managing
  • inputs from the user
  • responses from pending tasks
  • timeouts

Typically, state machine implementations are structured as forest of trees, with:

  • states as root nodes,
  • inputs/transitions as intermediate nodes,
  • handlers and actions as leaf nodes. Inputs are handled by walking down the tree from the root of node of current state.

Note: A tree specification can grow very quickly as the number of states and events increase.

In contrast, StateM uses:

  • A flat list with composite keys of event + state;
  • Pattern matching on keys collapses the search space.

In StateM, state machines are specified as an ordered list of handlers keyed by Event x State patterns. Events drive the state machine and are externally triggered or internally generated by the state machine. Pending events are tracked on a priority queue that preserves entry order. State machines can hold arbitrary data which is provided to, and can be mutated by, its event handlers.

Features

  • Co-located code: Event, states, transitions and actions in one place.
  • Inserted Events: Insert events from within the specification.
  • State Entry Events: Automatically generates Entry events on state change.
  • Timeouts: Install timeouts for state transitions, new events, or just plain timeouts.

Installation

Install with npm:

npm install --save gen-statem

Usage

State machines implement the IStateMachine interface.

Create a StateMachine

You can create a state machine by calling StateMachine's constructor and passing it a list of handlers, the initial state and optional data.

Note: The state machine’s data type TData is a type argument (StateMachine<TData>).

For example, the state machine below toggles states ONE and TWO on event next. npm.io

let sm = new StateMachine<void>({
    handlers: [
        ['cast#next#ONE', 'TWO'],
        ['cast#next#TWO', () => nextState('ONE')],
    ],
    initialState: "ONE",
})

sm.startSM()

sm.on('stateChanged', (state, old, data, event) => {
    console.log(`${old} --> ${state} on ${event ? event.toString() : ''}`)
})

sm.cast('next')
// ONE --> TWO on Cast@Low { context: 'next' }

sm.cast('next')
// TWO --> ONE on Cast@Low { context: 'next' }

Subclassing StateMachine

Extending the StateMachine class lets you:

  • Declare and implement a public API for your state machine (and wrap call/cast dispatch calls).
  • Provide helper functions.

Note: Handler functions are called with this set to the state machine instance.

class PingPong extends StateMachine<void> {
    handlers: Handlers<void> = [
        ['cast#next#ONE', 'TWO'],
        ['cast#next#TWO', 'ONE'],
    ]

    initialState = "ONE"

    // Define our public API
    next() {
        this.cast('next')
    }
}

let sm = new PingPong()
sm.startSM()

sm.on('stateChanged', (state, old, data, event) => {
    console.log(`${old} --> ${state} on ${event ? event.toString() : ''}`)
})

sm.next()
// ONE --> TWO on Cast@Low { context: 'next' }

sm.next()
// TWO --> ONE on Cast@Low { context: 'next' }

In the Browser

Fetch it from npm via unpkg:

<script src="https://unpkg.com/gen-statem/dist/umd/gen-statem.js"></script>

React State Management

StateM accepts a DataProxy object to synchronize its internal data with external objects such as React components.

Terminology: State machine data == React component state

class App extends React.Component {

  constructor() {
    this.sm = new StateMachine( {
      dataProxy: {
        get: () => this.state,
        set: ( data, state ) => this.setState( { ...data, currentState: state } ),
      },
    } )
  }
}

Details

Routes

StateM uses the url path to parameterized route matching seen in express et. al.

Current Event Routes

The current event and current state are mapped to a route string as:

<current event>#<event context>#<current state>

For example:

Event

Event Context

Current State

Route

cast

"flip"

off

"cast#flip#off"

cast

{button: 2}

locked

"cast#button/2#off"

call

"getInfo"

one

"call/internalId#getInfo/2#one"

Event Handler Routes

In addition to string literals, handler routes can include:

  • Parameter capture patterns (:param) capture up to the next /, # or the end of the route.
  • Splats (*param) capture from up to # or the end of the route.
  • Parts of the route can be marked optional by wrapping in parenthesis. Optional parts can include parameter capture and splats.

For example:

  • cast#flip#:state will match a cast(flip) event in any state and provide the current state as an argument (args.param) to the handler.
  • call/:from#getInfo#:state will capture the callerId and state as args.from and args.state respectively.
  • "cast#button/:digit#locked will capture a button press in the locked state and provide the digit value in args.digit.
  • "cast#*context#open intercepts any cast events in state open regardless of the parameters passed when cast was invoked (note: the splat will be available as args.context).

Event Handlers

A key part of a state machine specification is the list of (route, handler) tuples:

(string | Array<string>, function | string | [string, string | number])

Note: Event handlers are specified as an array to preserve order (vs. objects, where propertyiteration order is arbitrary).

Multiple Routes to a Handler

When a route is specified as an array, it is treated as a boolean OR, i.e. if any route matches an incoming event route, the corresponding handler is invoked.

Handler Functions

Handler functions receive the following:

type HandlerOpts<TData> = {
    args: { [k in string]: string },
    current: State,
    data: TData,
    event: Event,
    route: string
}
  • current: the state machine’s current state.
  • data: the state machine’s current data.
  • args: any arguments or splats parsed from the incoming event’s route.
  • event: the actual incoming event.
  • route: the event’s route.

Handler functions can return:

  • ResultBuilder a fluent builder for Results.
  • Result - verbose, not recommended.
  • void - interpreted as keepStateAndData

string Handlers

Instead of a handler function, you can provide a

  • state: string - interpreted as a next state directive.
  • [state: string, timeout: string | number] - interpreted as a next state directive and event timeout.

Result Builder

StateM provides a fluent interface (ResultBuilder) for specifying state transitions and actions in handler functions.

The following functions return a ResultBuilder:

  • keepState: Instructs the state machine to keep the current state (i.e. transition to the same state). Does not emit a state entry event.
  • repeatState: Like keepState, but emits a state entry event.
  • nextState(state): Instructs the state machine to transition to the given state (can be the current state).

ResultBuilder Methods

ResultBuilders provide the following chainable methods:

  • data(spec): Instructs the state machine to mutate state data with the given spec.
  • eventTimeout(time): Starts the event timer which may result in a EventTimeoutEvent event if a new event is not received.
  • stateTimeout(time): Starts the state timer which may result in a StateTimeoutEvent event if a state transition does not occur.
  • timeout(time, name): Starts a generic timer with an optional name which may result in a GenericTimeoutEvent. Calling timeout(name) will cancel the event if it has not yet fired.
  • nextEvent(type, context, extra): Inserts an event of the given type at the front of the queue so that it is executed before pending events.
  • internalEvent(context, extra): Like nextEvent, this method inserts an InternalEvent event.
  • postpone: Instructs the state machine to postpone the current until the state changes at which point any postponed events are delivered before pending events.
  • reply(from, msg): Instructs the state machine to reply to the sender with id from; the result of a prior invocation of call.

Mutating State Machine Data

State machine data is mutated by calling the data on ResultBuilder with immutability-helper commands, including:

  • {$push: array} push() all the items in array on the target.
    • {$unshift: array} unshift() all the items in array on the target.
    • {$splice: array of arrays} for each item in arrays call splice() on the target with the parameters provided by the item. Note: The items in the array are applied sequentially, so the order matters. The indices of the target may change during the operation.* * {$set: any} replace the target entirely.
    • {$toggle: array of strings} toggles a list of boolean fields from the target object.
    • {$unset: array of strings} remove the list of keys in array from the target object.
    • {$merge: object} merge the keys of object with the target.
    • {$apply: function} passes in the current value to the function and updates it with the new returned value.
    • {$add: array of objects} add a value to a Map or Set. When adding to a Set you pass in an array of objects to add, when adding to a Map, you pass in [key, value] arrays like so: update(myMap, {$add: [['foo', 'bar'], ['baz', 'boo']]})
    • {$remove: array of strings} remove the list of keys in array from a Map or Set.

Event Types

  • CallEvent: Sends a event of type call to the state machine and returns a pending Promise.
    • Call call(context, extra) to emit.
    • Event handlers can reply with reply() action which resolves the pending Promise returned by call().
    • Internally, call generates a from id to identify the caller.
    • The event route for call is: call/<from>#<context>#<state>.
  • CastEvent: Sends a event of type cast to the state machine and returns without waiting for a result.
    • Call cast(context, extra) to emit.
    • The event route for cast is: cast#<context>#<state>.
  • EnterEvent: Sends a enter event to the state machine.
    • Internally generated by the state machine on a state transition. If the state machine is transitioning to the same state, enter is emitted if the previous event handler returns repeatState (and not for either of keepState, or nextState(same state)).
  • EventTimeoutEvent: Sends a eventTimeout event to the state machine.
    • Internally generated by the state machine when the eventTimeout timer fires.
    • The eventTimeout timer is started by invoking eventTimeout(timeout) in a event handler.
    • The event route for eventTimeout is: eventTimeout#<context>#<state>.
    • Can be cancelled by calling eventTimeout() without a timeout argument from an event handler.
  • GenericTimeoutEvent: Sends a genericTimeout event to the state machine.
    • Internally generated by the state machine when the (optionally named) genericTimeout timer fires.
    • A genericTimeout timer is started by invoking genericTimeout(timeout [, name]) in a event handler.
    • The event route for genericTimeout is: genericTimeout#<context>#<state>.
    • Can be cancelled by calling genericTimeout([name]) without a timeout argument from an event handler.
  • StateTimeoutEvent: Sends a stateTimeout event to the state machine.
    • Internally generated by the state machine when the stateTimeout timer fires.
    • The stateTimeout timer is started by invoking stateTimeout(timeout) in a event handler.
    • The event route for stateTimeout is: stateTimeout#<context>#<state>.
    • Can be cancelled by calling stateTimeout() without arguments from an event handler.
  • InternalEvent: Sends a internal event to the state machine. This is a deliberately named event to let the state machine know that the event is internal.
    • Internally generated by invoking internal(context, extra) from a event handler.
    • The event route for internal is: internal#<context>#<state>.

Processing Events

The state machine looks for the first event handler whose key matches the incoming event x current state, or, a catch-all handler.

The matched handler is invoked with the incoming event, route matching arguments, the current state machine state and data.

The result of the handler invocation can include a state transition directive and transition actions, which are immediately executed, potentially changing the state machine’s state, mutating the internal data as well as the event queue.

Complex State

Examples

See the examples directory for more examples.

Toggle Button With Count

ToggleButtonCount maintains a counter in the state machine’s data that counts the number of times it turned on.

type ToggleButtonWithCountData = {
    count: number
}

export class ToggleButtonWithCount
    extends StateMachine<ToggleButtonWithCountData> {
    initialState = 'off'

    handlers: Handlers<ToggleButtonWithCountData> = [
        // if we get 'flip' in 'off', go to 'on'
        // and increment data.count
        ['cast#flip#off', ({data}) => nextState('on')
            .data({count: {$set: data.count + 1}})],

        // flip from on goes back to off.
        ['cast#flip#on', 'off']
    ]

    initialData: ToggleButtonWithCountData = {
        count: 0
    }

    flip() {
        this.cast('flip')
    }
}
let button = new ToggleButton()
button.startSM()

// initial state
console.log(await button.getState())    // 'off'

button.flip()
console.log(await button.getState())    // 'on'

button.flip()
console.log(await button.getState())    // 'off'

Push Button Countdown Timer

PushButtonCountdownTimer turns on when pushed and starts a genericTimeout. The button turns off when the timer fires.

type PushButtonCountdownTimerData = {
    timeout: Timeout
}

class PushButtonCountdownTimer extends StateMachine<PushButtonCountdownTimerData> {
    initialState = 'off'

    handlers: Handlers<PushButtonCountdownTimerData> = [
        // Start a generic timer and go to 'on'
        ['cast#push#off', ({data}) => nextState('on').timeout(data.timeout)],

        // when we get 'genericTimeout' in 'on',
        // go back to 'off'
        ['genericTimeout#*_#on', 'off']
    ]

    constructor(timeout: Timeout) {
        super()
        this.initialData = {timeout}
    }

    push() {
        this.cast('push')
    }
}

A Hotel Safe

See npm.io HotelSafe simulates a type of safe frequently seen in hotel rooms.

  • Locking the Safe

    • Press Reset (R). The display will prompt for a new code.
    • Enter a 4 digit code and then press Lock (L).
    • The code will flash. The safe is now locked.
  • Unlocking the Safe

    • Enter the 4 digit code.
    • The safe will flash OPENED if the code is correct. The safe is now open.
    • If the code is incorrect, the display will flash ERROR and the safe will stay locked.
/**
 * The state machine's data type
 */
type SafeData = {
    code: Array<number>,
    input: Array<number>,
    timeout: Timeout,
    codeSize: number,
    message?: string
}

export default class HotelSafe extends StateMachine<SafeData> {
    handlers: Handlers<SafeData> = [

        // Clear data when safe enters OPEN
        ['enter#*_#open', () => keepState().data({
            code: {$set: []},
            input: {$set: []},
            message: {$set: 'Open'},
        })],

        // User pressed RESET -- get new code
        ['cast#reset#open', () => nextState('open/locking').data({
            message: {$set: 'Enter Code'},
        })],

        // Track the last {codeSize} digits.
        // show code on display. Repeat state for setting timeout
        ['cast#button/:digit#open/locking', ({args, data}) => {
            let code = pushFixed(Number(args.digit), data.code, data.codeSize)
            return repeatState().data({
                code: {$set: code},
                message: {$set: code.join('')},
            })
        }],

        // User pressed LOCK. CLose safe if code is long enough
        // else, repeat state (sets timeout on reentry)
        ['cast#lock#open/locking', ({data}) =>
            data.code.length !== data.codeSize ?
            repeatState() :
            nextState('closed/success').data({
                message: {$set: `**${data.code.join('')}**`},
            })],

        // Clear input when safe is closed
        ['enter#*_#closed', () => keepState().data({
            input: {$set: []},
            message: {$set: 'Locked'}
        })],

        // Postpone button press and go to closed/unlocking
        ['cast#button/*_#closed', ({}) =>
            nextState('closed/unlocking').postpone()],

        // User entered digit(s).
        // Keep state if code is not long enough
        // OPEN if input matches code
        // go to MESSAGE if code does not match and set a timeout
        ['cast#button/:digit#closed/unlocking', ({args, data}) => {
            let digit = Number(args.digit)
            let input = data.input.concat(digit)

            // code is the correct length. Decision time.
            if (input.length >= data.code.length) {
                let [state, msg] = arrayEqual(data.code, input) ?
                    ['open/success', "Opened"] :
                    ['closed/error', "ERROR"]

                return nextState(state).data({message: {$set: msg}})
            }

            // Not long enough. Keep collecting digits.
            // Show masked code. Repeat state for
            // setting timeout
            return repeatState().data({
                input: {$push: [digit]},
                message: {$set: "*".repeat(input.length)}
            })
        }],

        // These states timeout on inactivity (eventTimeout)
        [['enter#*_#open/locking',
            'enter#*_#closed/unlocking'], ({data}) =>
            keepState().eventTimeout(data.timeout)],

        // these states just timeout
        ['enter#*_#:state/*_', ({data}) =>
            keepState().timeout(data.timeout)],

        // If we timeout in a sub state, go to the base state
        [['genericTimeout#*_#:state/*_',
            'eventTimeout#*_#:state/*_',], ({args}) =>
            nextState(args.state)],
    ]

    initialData: SafeData = {
        code: [],
        codeSize: 4,
        timeout: 200,
        input: [],
    }

    initialState = 'open'

    constructor(timeout: Timeout) {
        super()
        this.initialData.timeout = timeout
    }

    /**
     * Safe Interface. casts 'reset'
     */
    reset() {
        this.cast('reset')
    }

    /**
     * Safe Interface. cast 'lock'
     */
    lock() {
        this.cast('lock')
    }

    /**
     * Safe Interface. send button digit
     * @param digit
     */
    button(digit: number) {
        this.cast({button: digit})
    }
}

Here’s the hotel safe’s state diagram. The collection of open(/*) states (and likewise, closed(/*) states) represents a state machine and the rule of thumb is to implement it in a separate machine. Complex states however, make the job easier for simple hierarchies, as shown above.

npm.io

Index

Enumerations

Classes

Interfaces

Type aliases

Variables

Functions


Type aliases

ActionType

Ƭ ActionType: "reply" | "nextEvent" | "postpone" | "stateTimeout" | "eventTimeout" | "genericTimeout" | "emit"

Transition actions


AnimOptions

Ƭ AnimOptions: object

Type declaration

Optional delay: undefined | number

Delay between context switches

Optional includeDefault: undefined | false | true

Include switching when a default handler is involved


ComplexState

Ƭ ComplexState: Array<string> | NamedPrimitiveObject

A complex state. If an object, must have a 'name' key which specifies the base state name.


DataProxy

Ƭ DataProxy: object

Type declaration

get : function ▸ get(): T

Returns: T

set : function ▸ set(value: T, state: State): void

Parameters:

ParamType
valueT
stateState

Returns: void


EventContext

Ƭ EventContext: Primitive | PrimitiveObject

Events can accept simple arguments of type {Primitive}, or a {PrimitiveObject}


EventExtra

Ƭ EventExtra: any

Non {Primitive|PrimitiveObject} arguments to events.


EventType

Ƭ EventType: "call" | "cast" | "enter" | "eventTimeout" | "stateTimeout" | "genericTimeout" | "internal"

State machine events


From

Ƭ From: string

The caller's address for a {CallEvent}


Handler

Ƭ Handler: HandlerFn<TData> | State | NextStateWithEventTimeout

Route handler. Can be a function, a state or a [state, event timeout] tuple


HandlerFn

Ƭ HandlerFn: function

Handler function

Type declaration

▸(opts: HandlerOpts<TData>): HandlerResult2<TData>

Parameters:

ParamType
optsHandlerOpts<TData>

Returns: HandlerResult2<TData>


HandlerOpts

Ƭ HandlerOpts: object

Values passed to a {Handler}

Type declaration

args: object

current: State

data: TData

event: Event

route: string


HandlerResult

Ƭ HandlerResult: Result | ResultWithData<TData> | ResultBuilder | void

Result of a handler invocation. {ResultBuilder.getResult()} is invoked to convert a {ResultBuilder} to a {Result} {void} implies {KeepStateWithData}


HandlerResult2

Ƭ HandlerResult2: HandlerResult<TData> | Promise<HandlerResult<TData>>


HandlerRoute

Ƭ HandlerRoute: string | Array<string>

The key of a handler. Can be {string|Array}. If {Array}, the routes are treated as a boolean OR. Any matching route in the array will invoke the handler.


HandlerSpec

Ƭ HandlerSpec: [HandlerRoute, Handler<TData>] | object

A {HandlerRoute} to {Handler} entry. Can be a tuple or object with string keys. If an object, the keys are treated as {string} routes.


Handlers

Ƭ Handlers: Array<HandlerSpec<TData>>

List of route handlers


NamedPrimitiveObject

Ƭ NamedPrimitiveObject: object

Like {PrimitiveObject}, but expects a key 'name'

Type declaration

k: string: Primitive

name: string


NextStateWithEventTimeout

Ƭ NextStateWithEventTimeout: [State, Timeout]

Handlers can be specified as a tuple of the next {State} and a {EventTimeout}


Primitive

Ƭ Primitive: number | string | boolean | null | undefined

Our definition of primitives (doesn't include symbol)


PrimitiveObject

Ƭ PrimitiveObject: object

Object with string keys and primitive values

Type declaration


ResultType

Ƭ ResultType: "none" | "keepState" | "keepStateAndData" | "nextState" | "nextStateWithData" | "repeatState" | "repeatStateAndData" | "stop" | "builder"

Hander results


State

Ƭ State: string | ComplexState

A State in the state machine.


Timeout

Ƭ Timeout: number | string

Timeout values. Milliseconds if {number}. For string values, see {NTimer}


Variables

<Const> Emittery

● Emittery: any = require('emittery')


<Const> StablePriorityQueue

● StablePriorityQueue: any = require("stablepriorityqueue")


Functions

data

data<TData>(spec: Spec<TData>): ResultBuilder

Helper to only mutate data

Type parameters:

TData

Parameters:

ParamTypeDescription
specSpec<TData>-

Returns: ResultBuilder


eventTimeout

eventTimeout(time: Timeout): KeepStateBuilder

Helper function to set the event timeout timer

Parameters:

ParamTypeDescription
timeTimeout-

Returns: KeepStateBuilder


internalEvent

internalEvent(context?: EventContext, extra?: EventExtra): KeepStateBuilder

Helper function to insert an 'internal' event

Parameters:

ParamTypeDescription
Optional contextEventContext-
Optional extraEventExtra-

Returns: KeepStateBuilder


isPromiseResult

isPromiseResult<TData>(r: any): boolean

Type parameters:

TData

Parameters:

ParamType
rany

Returns: boolean


keepState

keepState(): KeepStateBuilder

Returns a KeepStateBuilder

Returns: KeepStateBuilder


nextEvent

nextEvent(type: EventType, context?: EventContext, extra?: EventExtra): KeepStateBuilder

Helper function to insert a new event

Parameters:

ParamTypeDescription
typeEventType-
Optional contextEventContext-
Optional extraEventExtra-

Returns: KeepStateBuilder


nextState

nextState(state: State): NextStateBuilder

Returns a NextStateBuilder

Parameters:

ParamTypeDescription
stateState-

Returns: NextStateBuilder


repeatState

repeatState(): RepeatStateBuilder

Returns a RepeatStateBuilder

Returns: RepeatStateBuilder


reply

reply(from: string, msg: any): KeepStateBuilder

Helper function to return a reply

Parameters:

ParamTypeDescription
fromstring-
msgany-

Returns: KeepStateBuilder


stateTimeout

stateTimeout(time: Timeout): KeepStateBuilder

Helper function to set a state timeout (keeps state and data)

Parameters:

ParamTypeDescription
timeTimeout-

Returns: KeepStateBuilder


timeout

timeout(time: Timeout): KeepStateBuilder

helper function to set a generic timer

Parameters:

ParamTypeDescription
timeTimeout-

Returns: KeepStateBuilder


1.3.1

6 years ago

1.3.0

6 years ago

1.2.7

6 years ago

1.2.6

6 years ago

1.2.5

6 years ago

1.2.4

6 years ago

1.2.3

6 years ago

1.2.2

6 years ago

1.2.1

6 years ago

1.2.0

6 years ago

1.1.0

6 years ago

1.0.5

6 years ago

1.0.4

6 years ago

1.0.3

6 years ago

1.0.2

6 years ago

1.0.1

6 years ago

1.0.0

6 years ago