fsm-as-promised
Version:
A minimalistic finite state machine library using promises
432 lines (431 loc) • 16.9 kB
JavaScript
"use strict";
/*
* @author Vlad Stirbu
* @license MIT
*
* Copyright © 2014-2020
*/
Object.defineProperty(exports, "__esModule", { value: true });
const fsm_error_1 = require("./fsm-error");
const _ = require("lodash");
const events_1 = require("events");
const uuid_1 = require("uuid");
const stampit = require("stampit");
const AssignFirstArgumentStamp = stampit.compose({
init: function init(opts) {
Object.assign(this, opts);
},
});
const StateMachineStamp = stampit.compose({
props: {
// can be an object or an array
events: [],
pseudoStates: {},
responses: {},
pseudoEvents: {},
callbacks: {},
states: {},
final: null,
initial: 'none',
current: 'none',
},
statics: {
Promise: global.Promise || require('es6-promise').Promise,
FsmError: fsm_error_1.FsmError,
callbackPrefix: 'on',
noChoiceFound: 'no-choice',
type: function type(options) {
var Type = this.Type;
if (options.from === options.to || _.isUndefined(options.to)) {
return Type.NOOP;
}
else if (options.from === '*') {
return Type.GENERAL;
}
return Type.INTER;
},
Type: {
NOOP: 0,
INTER: 1,
GENERAL: 2,
},
isConditional: function isConditional(event) {
return _.isFunction(event.condition) && _.isArray(event.to);
},
pseudoEvent: function pseudoEvent(state, name) {
return state + '--' + name;
},
},
methods: {
emit: _.noop,
error: function (msg, options) {
if (this.target) {
options.instanceId = this.target.instanceId();
}
throw new this.factory.FsmError(msg, options);
},
instanceErrorHandler: function instanceErrorHandler(err, instanceId, action) {
if (err instanceof this.factory.FsmError) {
if (err.message === 'Invalid event in current state') {
if (err.instanceId !== instanceId) {
action();
}
}
else {
action();
}
}
else {
action();
}
},
canTransition: function canTransition(options) {
var factory = this.factory;
var Type = factory.Type;
switch (factory.type(options)) {
case Type.NOOP:
if (this.inTransition) {
this.error('Previous transition pending', options);
}
break;
case Type.INTER:
if (_.size(this.states[this.current].noopTransitions) > 0) {
options.pending = _.clone(this.states[this.current].noopTransitions);
this.error('Previous transition pending', options);
}
if (this.inTransition) {
this.error('Previous inter-state transition started', options);
}
this.inTransition = true;
break;
default:
}
return options;
},
can: function can(name) {
return Boolean(this.events[name][this.current]);
},
cannot: function cannot(name) {
return !this.can(name);
},
hasState: function hasState(state) {
return Boolean(this.states[state]);
},
is: function is(state) {
return state == this.current;
},
isFinal: function isFinal(state) {
state = state || this.current;
if (_.isArray(this.final)) {
return _.includes(this.final, state);
}
return this.final === state;
},
isValidEvent: function isValidEvent(options) {
if (this.cannot(options.name)) {
this.error('Invalid event in current state', options);
}
return options;
},
// internal callbacks
onenterstate: function onenterstate(options) {
const factory = this.factory;
const Type = this.factory.Type;
switch (factory.type(options)) {
case Type.NOOP:
delete this.states[this.current].noopTransitions[options.id];
break;
default:
this.inTransition = false;
this.current = options.to;
if (!this.pseudoStates[this.current]) {
this.emit('state', this.current);
}
}
return options;
},
onleavestate: function onleavestate(options) {
var factory = this.factory;
var Type = this.factory.Type;
switch (factory.type(options)) {
case Type.NOOP:
this.states[this.current].noopTransitions[options.id] = options;
break;
default:
}
return options;
},
returnValue: function returnValue(options) {
return options.res || options;
},
revert: function (options) {
return function revert(err) {
var factory = this.factory;
var Type = this.factory.Type;
var instanceId = this.target.instanceId();
switch (factory.type(options)) {
case Type.INTER:
this.instanceErrorHandler(err, instanceId, () => {
this.inTransition = false;
});
break;
case Type.NOOP:
this.instanceErrorHandler(err, instanceId, () => {
delete this.states[this.current].noopTransitions[options.id];
});
break;
default:
}
throw err;
};
},
// configure methods
addEvents: function addEvents(events) {
_.forEach(events, function (event) {
this.addEvent(event);
}.bind(this));
},
addEvent: function addEvent(event) {
this.events[event.name] = this.events[event.name] || {};
//NOTE: Add the choice pseudo-state for conditional transition
if (this.factory.isConditional(event)) {
return this.addConditionalEvent(event);
}
this.addBasicEvent(event);
},
addBasicEvent: function addBasicEvent(event) {
if (_.isArray(event.to)) {
this.error('Ambigous transition', event);
}
event.from = [].concat(event.from || []);
_.forEach(event.from, function (from) {
this.events[event.name][from] = event.to || from;
}.bind(this));
},
addConditionalEvent: function addConditionalEvent(event) {
var pseudoState;
var factory = this.factory;
var callbackPrefix = factory.callbackPrefix;
var noChoiceFound = factory.noChoiceFound;
var pseudoEvent = factory.pseudoEvent;
var Promise = factory.Promise;
if (_.isArray(event.from)) {
return _.forEach(event.from, function (from) {
this.addConditionalEvent({
name: event.name,
from: from,
to: event.to,
condition: event.condition,
});
}.bind(this));
}
pseudoState = event.from + '__' + event.name;
this.pseudoStates[pseudoState] = event.from;
this.addState(pseudoState);
this.addEvent({
name: event.name,
from: event.from,
to: pseudoState,
});
this.addEvent({
name: pseudoEvent(pseudoState, noChoiceFound),
from: pseudoState,
to: event.from,
});
this.pseudoEvents[pseudoEvent(pseudoState, noChoiceFound)] = event.name;
_.forEach(event.to, function (toState) {
this.addEvent({
name: pseudoEvent(pseudoState, toState),
from: pseudoState,
to: toState,
});
this.pseudoEvents[pseudoEvent(pseudoState, toState)] = event.name;
}.bind(this));
this.callbacks[callbackPrefix + 'entered' + pseudoState] = function (options) {
var target = this.target;
_.defaults(options, {
args: [],
});
return new Promise(function (resolve) {
resolve(event.condition.call(target, options));
}).then(function (index) {
var toState;
if (_.isNumber(index)) {
toState = event.to[index];
}
else if (_.includes(event.to, index)) {
toState = index;
}
if (_.isUndefined(toState)) {
return target[pseudoEvent(pseudoState, noChoiceFound)]().then(this.error.bind(this, 'Choice index out of range', event));
}
else {
return target[pseudoEvent(pseudoState, toState)].apply(target, options.args);
}
}.bind(this));
}.bind(this);
},
addState: function addState(state) {
var states = this.states;
state = [].concat(state || []);
state.forEach(function (name) {
states[name] = states[name] || {
noopTransitions: {},
};
});
},
preprocessPseudoState: function preprocessPseudoState(name, options) {
var responses = this.responses;
// transition to choice state in a conditional event
Object.defineProperty(options, 'res', {
get: function getRes() {
return responses[name];
},
set: function setRes(value) {
responses[name] = value;
},
});
// reset previous results
delete responses[name];
return options;
},
preprocessPseudoEvent: function preprocessPseudoEvent(name, options) {
// transition from choice state in a conditional event
var pseudoEvent = this.pseudoEvents[name];
var responses = this.responses;
var pseudoStates = this.pseudoStates;
var pOptions = {
name: pseudoEvent,
from: pseudoStates[this.current],
to: options.to,
args: options.args,
};
Object.defineProperties(pOptions, {
res: {
get: function () {
return responses[pseudoEvent];
},
set: function (val) {
responses[pseudoEvent] = val;
},
},
});
return pOptions;
},
buildEvent: function buildEvent(name) {
var callbacks = this.callbacks;
var pseudoEvents = this.pseudoEvents;
var pseudoStates = this.pseudoStates;
var events = this.events;
var Type = this.factory.Type;
var callbackPrefix = this.factory.callbackPrefix;
return function triggerEvent() {
var args = _.toArray(arguments);
var current = this.current;
var target = this.target;
var options = {
name: name,
from: current,
to: events[name][current],
args: args,
};
var pOptions;
var isPseudo = pseudoEvents[name];
if (options.from === options.to) {
options.id = (0, uuid_1.v4)();
}
if (pseudoStates[options.to]) {
options = this.preprocessPseudoState(name, options);
}
if (isPseudo) {
pOptions = this.preprocessPseudoEvent(name, options);
}
return (new this.factory.Promise(function (resolve) {
resolve(options);
})
.then(this.isValidEvent.bind(this))
.then(this.canTransition.bind(this))
.then(callbacks[callbackPrefix + 'leave' + current]
? callbacks[callbackPrefix + 'leave' + current].bind(target, options)
: _.identity)
.then(callbacks.onleave
? callbacks.onleave.bind(target, options)
: _.identity)
.then(this.onleavestate.bind(this, options))
.then(callbacks[callbackPrefix + name]
? callbacks[callbackPrefix + name].bind(target, options)
: _.identity)
//in the case of the transition from choice pseudostate we provide
// the options of the original transition
.then(callbacks[callbackPrefix + 'enter' + events[name][current]]
? callbacks[callbackPrefix + 'enter' + events[name][current]].bind(target, isPseudo ? pOptions : options)
: _.identity)
.then(callbacks.onenter && !pseudoStates[options.to]
? callbacks.onenter.bind(target, isPseudo ? pOptions : options)
: _.identity)
.then(this.onenterstate.bind(this, options))
.then(callbacks[callbackPrefix + 'entered' + events[name][current]]
? callbacks[callbackPrefix + 'entered' + events[name][current]].bind(target, isPseudo ? pOptions : options)
: _.identity)
.then(callbacks.onentered && !pseudoStates[options.to]
? callbacks.onentered.bind(target, isPseudo ? pOptions : options)
: _.identity)
.then(this.returnValue.bind(this, options))
.catch(this.revert(options).bind(this)));
}.bind(this);
},
initTarget: function initTarget(target) {
var mixin;
const id = (0, uuid_1.v4)();
if (!_.isObject(target)) {
target = new events_1.EventEmitter();
}
if (_.isFunction(target.emit)) {
this.emit = function emit() {
return target.emit.apply(target, arguments);
};
}
mixin = _.mapValues(this.events, function (event, name) {
return this.buildEvent(name);
}.bind(this));
_.assign(target, mixin, {
can: this.can.bind(this),
cannot: this.cannot.bind(this),
is: this.is.bind(this),
hasState: this.hasState.bind(this),
isFinal: this.isFinal.bind(this),
instanceId: () => id,
});
Object.defineProperty(target, 'current', {
get: function getCurrent() {
return this.current;
}.bind(this),
});
this.target = target;
return target;
},
},
init: function init(opts, { stamp, args }) {
this.factory = stamp;
this.states = {};
var events = this.events;
this.events = {};
_.forEach(events, function (event, name) {
if (_.isString(name)) {
event.name = name;
}
this.addEvent(event);
//NOTE: Add states
this.addState(event.from);
this.addState(event.to);
}.bind(this));
this.current = this.initial;
// return this.initTarget(_.first(context.args));
const target = this.initTarget(args[1]);
return target;
},
});
const StateMachine = stampit
.compose(AssignFirstArgumentStamp)
.compose(StateMachineStamp);
exports.default = StateMachine;