@urbn/state-machine
Version:
A small Javascript Promise-based Finite State Machine implementation
371 lines (352 loc) • 13.5 kB
JavaScript
/* eslint-disable no-underscore-dangle */
function isFunction(fn) {
return typeof fn === 'function';
}
function isPlainObject(obj) {
return Object.prototype.toString.call(obj) === '[object Object]';
}
/**
* @private
* @description
* Safely get a deeply nested path from a potentially null object, returning the
* provided default value if the path is not found
*
* @example
* const obj = {
* foo: {
* bar: {
* baz: 5,
* },
* },
* };
* get(obj, 'foo.bar.baz', 0); // === 5
* get(obj, 'foo.bar.junk', 0); // === 0
*
* @param {Object} obj Source object
* @param {String} pathString Dot-separated path
* @param {*} [defaultVal] Optional default value
* @returns {*} Value at nested path, or `defaultVal` if not present
*/
function get(obj, pathString, defaultVal) {
if (obj == null || typeof pathString !== 'string') {
return defaultVal;
}
let pointer = obj;
const toks = pathString.split('.');
for (let i = 0; i < toks.length; i++) {
// Note: don't use hasOwnProperty because it doesn't work against
// prototypical ES6 class properties, such as TokenSession.authToken
if (pointer &&
typeof pointer !== 'boolean' &&
typeof pointer[toks[i]] !== 'undefined') {
pointer = pointer[toks[i]];
} else {
return defaultVal;
}
}
return pointer;
}
/**
* @private
* @description
* Checks whether a given object has the provided key, using `hasOwnProperty`
*
* @param {Object} obj Source object
* @param {String} key Property name
* @returns {Boolean} `true` if the property exists, `false` otherwise
*/
function has(obj, key) {
if (obj == null ||
typeof obj !== 'object' ||
key == null ||
typeof key !== 'string') {
return false;
}
return Object.hasOwnProperty.call(obj, key);
}
/**
* @private
* @description
* Wrapper around get() that provides the ability to pass a validation
* function to evaluate against the value
*
* @param {Object} obj Object to retrieve against
* @param {String} path Path on object
* @param {Function} validator Validation function against retrieved value
* @param {Object} defaultVal Default value to return if value doesn't
* exist or is invalid
* @returns {Object} Validated value, or defaultVal
*/
function getValidated(obj, path, validator, defaultVal) {
const value = get(obj, path);
if (!isFunction(validator) || validator(value)) {
return value;
}
return defaultVal;
}
/**
* @description
* A tiny Finite State Machine implementation for use in complex UI scenarios.
* The constructor will throw an `Error` if the machine definition is invalid.
*
* We decided to roll our own after looking at the following libraries and
* deciding against using them:
* - [xstate](https://github.com/davidkpiano/xstate)
* - Too big (9Kb gzipped)
* - [javascript-state-machine](https://github.com/jakesgordon/javascript-state-machine)
* - Dislike the transition-first definition approach
* - [Stately.js](https://github.com/fschaefer/Stately.js)
* - good implementation, but doesn't expose internals very well and hard
* to wire up to Vue's reactivity
*
* A machine definition is of the following format:
*
* ```js
* {
* // 'states' is a map of state names and their state definitions
* states: {
* stateName: {
* // 'transitions' is a map of valid transitions from this state
* transitions: {
* transitionName: 'destinationStateName',
* },
* onEnter(payload, newState, oldState, transition) {
* // Called whenever this state is entered. Usually used to launch
* // any side-effects and eventually transition to subsequent states.
* // The payload is the data, if any, that was passed to
* // StateMachine.transition()
* },
* },
* ...
* },
* onEnter(payload, newState, oldState, transition) {
* // Global onEnter function called every time we enter a new
* // state. This is useful for propagating that information back
* // to Vue's reactive properties. The payload is the data, if any,
* // that was passed to StateMachine.transition()
* }
* }
* ```
*
* @example
* const machine = new StateMachine({
* states: {
* green: {
* transitions: { change: 'yellow' },
* },
* yellow: {
* transitions: { change: 'red' },
* },
* red: {
* transitions: { change: 'green' },
* },
* },
* onEnter(data, newState, oldState, transition) {
* console.log(`Transitioned from ${oldState} -> ${newState}`);
* },
* }, 'red');
*
* setInterval(() => testMachine.transition('change'), 3000);
*/
module.exports = class StateMachine {
/**
* @description
* None - the docs display the description from the class JSDoc block :)
*
* @param {Object} machine Machine definition
* @param {String} initialState Initial state for the state machine
* @param {Function} initialStateErrorHandler Process initialState's onEnter errors
* @returns {StateMachine} Initialized StateMachine instance
* @throws {Error} If no states exist, or transitions to
* invalid states are found
*/
constructor(machine, initialState, initialStateErrorHandler) {
this._machine = machine;
this._currentState = null;
StateMachine._validateMachine(machine, initialState);
this._setState(initialState).catch((err) => {
if (isFunction(initialStateErrorHandler)) {
initialStateErrorHandler(err);
}
});
}
/**
* @private
* @description
* Internal method to validate the state machine definition object. Namely,
* that at least one state exists, and all transitions from all states lead
* to other valid states
*
* @param {Object} machine Machine definition
* @param {String} initialState Initial state
* @returns {undefined} No return value
* @throws {Error} If no states exist, invalid initial state is specified,
* or transitions to invalid states are found
*/
static _validateMachine(machine, initialState) {
const states = getValidated(machine, 'states', isPlainObject);
if (!states) {
throw new Error('Invalid states');
}
if (!has(states, initialState)) {
throw new Error(`Invalid initial state: ${initialState}`);
}
Object.entries(states).forEach(([, state]) => {
if (state && isPlainObject(state.transitions)) {
Object.entries(state.transitions).forEach(([transition, destState]) => {
if (!has(machine.states, destState)) {
throw new Error(`Invalid destination state for transition: ${transition}`);
}
});
}
});
}
/**
* @description
* Returns the current state for the state machine
*
* @returns {String} Current state
*/
get currentState() {
return this._currentState;
}
/**
* @private
* @description
* Internal debugging logger function
*
* @param {...*} args Any number of arguments o pass along to `console.debug`
* @returns {undefined} No return value
*/
static _debug(...args) {
if (process.env.NODE_ENV !== 'production') {
console.debug.apply(console, ['[DEBUG] [state-machine]', ...args]);
}
}
/**
* @private
* @description
* Internal error logger function
*
* @param {...*} args Any number of arguments o pass along to `console.error`
* @returns {undefined} No return value
*/
static _error(...args) {
console.error.apply(console, ['[ERROR] [state-machine]', ...args]);
}
/**
* @private
* @description
* Set the current state of the state machine, and call all associated
* `onEnter` functions
*
* @param {String} state New state
* @param {String} transition Transition that caused the state change
* @param {*} data Data payload from `StateMachine.transition()`
* @returns {Promise} Promise, resolved or rejected based on the state's
* onEnter function
*/
_setState(state, transition, data) {
// Ignore this from coverage. It shouldn't be possible since we validate
// transition states up front - but if a naughty developer uses _setState
// directly or something, we should be defensive
// istanbul ignore if
if (!has(this._machine.states, state)) {
const msg = `Cannot transition to invalid state ${state}`;
StateMachine._error(msg);
return Promise.reject(new Error(msg));
}
StateMachine._debug(`Setting new state: ${state}`);
const oldState = this.currentState;
this._currentState = state;
if (isFunction(this._machine.onEnter)) {
this._machine.onEnter(data, state, oldState, transition);
}
return new Promise((resolve, reject) => {
if (this._machine.states[state] && isFunction(this._machine.states[state].onEnter)) {
const retVal = this._machine.states[state]
.onEnter(data, state, oldState, transition);
// Respect promises returned by onEnter functions
if (retVal && isFunction(retVal.then)) {
retVal.then(resolve, reject);
return;
}
resolve(retVal);
return;
}
resolve();
});
}
/**
* @description
* Get a string `.dot` file representing the StateMachine that can be opened
* using GraphViz. Only available in development builds.
*
* @returns {String} The generated `.dot` file
*/
getDotFile() {
// istanbul ignore else
if (process.env.NODE_ENV !== 'production') {
const stateNames = Object.keys(this._machine.states);
const states = stateNames.reduce((acc, k) => `${acc} "${k}";\n`, '').trim();
const transitions = stateNames.reduce((acc, from) => {
const stateTransitions =
this._machine.states[from].transitions || /* istanbul ignore next */ {};
const str = Object.entries(stateTransitions).reduce(
(acc2, [t, to]) => `${acc2} "${from}" -> "${to}" [label="${t}"];\n`,
'',
).trim();
return str ? `${acc} ${str}\n` : /* istanbul ignore next */ acc;
}, '').trim();
return `
digraph "fsm" {
${states}
${transitions}
}`.trim();
}
// istanbul ignore next
return '';
}
/**
* @description
* Transition the StateMachine to a new state. Will no-op if the indicated
* transition is not valid for the current state. The returned promise will
* be resolved/rejected in the following cases:
*
* * If the transition is invalid, the returned promise will be rejected immediately
* * If the destination state does not define and `onEnter` function, the
* returned promise will be resolved immediately
* * If the destination state's `onEnter` function does not return a promise,
* the returned promise will be resolved immediately
* * Otherwise, the promise returned from onEnter will be returned
*
* @param {String} transition Transition name
* @param {*} [data] Optional payload to pass to `onEnter` functions
* @returns {Promise} Promise resolved upon successful transition, rejected on
* invalid transitions
*/
transition(transition, data) {
const state = this._machine.states[this.currentState];
if (!this.isValidTransition(transition)) {
const msg =
`Invalid transition (${transition}) from current state` +
`(${this.currentState})`;
StateMachine._error(msg);
return Promise.reject(new Error(msg));
}
StateMachine._debug(`Executing transition: ${transition}`);
return this._setState(state.transitions[transition], transition, data);
}
/**
* @description
* Is the specified transition valid from the current state?
*
* @param {String} transition Transition name
* @returns {Boolean} True if the transition is defined for the
* current state, false otherwise
*/
isValidTransition(transition) {
const state = this._machine.states[this.currentState];
return has(state.transitions, transition);
}
};