(function(f){if(typeof exports==="object"&&typeof module!=="undefined"){module.exports=f()}else if(typeof define==="function"&&define.amd){define([],f)}else{var g;if(typeof window!=="undefined"){g=window}else if(typeof global!=="undefined"){g=global}else if(typeof self!=="undefined"){g=self}else{g=this}g.jiboStateMachine = f()}})(function(){var define,module,exports;return (function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);var f=new Error("Cannot find module '"+o+"'");throw f.code="MODULE_NOT_FOUND",f}var l=n[o]={exports:{}};t[o][0].call(l.exports,function(e){var n=t[o][1][e];return s(n?n:e)},l,l.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o { this._addTransition(trans); }); } else { this._addTransition(transition); } return this; } _addTransition(transition) { if (transition.getSourceState()) { this.sm._rejectCurrentPromise(new Error(`This transition has already been assigned to a source state`)); } transition._setSourceState(this); this.transitions.push(transition); } addInternalTransition(name, destState) { const exists = this._internals.some(i => (i.name === name && i.destState === destState)); if (exists) { throw new Error(`State already has internal transition of name '${name}' to state '${destState.name}'`); } let internal = { name, destState, transition: new Transition_1.Transition(name, destState) }; this._internals.push(internal); this.addTransition(internal.transition); return this; } addEventTransition(event, destState) { return this._addEventTransition(event, destState, false); } _addEventTransition(event, destState, global = false) { if (this._events.has(event)) { throw new Error(`Duplicate event transition. State: '${this.name}', event: '${event}'`); } let trans = new Transition_1.Transition(Utils_1.createEventTransitionName(event), destState); trans._isGlobal = global; const handler = (result) => { if (this._isCurrent) { trans.trigger(result); } }; this._events.set(event, { event, handler }); this.addTransition(trans); return this; } addTypedEventTransition(event, destState) { return this._addTypedEventTransition(event, destState, false); } _addTypedEventTransition(event, destState, global = false) { let trans = new Transition_1.Transition(Utils_1.createTypedEventTransitionName(event), destState); trans._isGlobal = global; const handler = (result) => { if (this._isCurrent) { trans.trigger(result); } }; this._typedEvents.set(event, { event, handler }); this.addTransition(trans); return this; } addDoneTransition(destState) { if (this._doneTransition) { this.sm._rejectCurrentPromise(new Error(`Done transition for state '${this.name}' already registered`)); } this._doneTransition = new Transition_1.Transition('Done', destState); this.addTransition(this._doneTransition); } _enter(transition, result) { return __awaiter(this, void 0, void 0, function* () { this._entryCounter++; this._isCurrent = true; this._lastIncomingResults = result; // Here we subscribe to all events this._events.forEach(eventCont => { this.sm.on(eventCont.event, eventCont.handler); }); this._typedEvents.forEach(eventCont => { eventCont.event.on(eventCont.handler); }); // Here we emit the sm transition event since all the state is appropriate at this time this.sm.stateChanged.emit(transition); // We evaluate this state's onEntry method if (this.onEntry) { try { const ret = this.onEntry(transition, result); if (ret instanceof Promise) { // If we have a done transition then we wait on the result from the promise // and pass it into the next state if (this._doneTransition) { const entryCounterThisTime = this._entryCounter; const retFromPromise = yield ret; if (this._isCurrent && this._entryCounter === entryCounterThisTime) { this._doneTransition.trigger(retFromPromise); } } else { ret.catch(error => { this.sm._rejectCurrentPromise(error); }); } } else { if (this._doneTransition) { this._doneTransition.trigger(ret); } } } catch (e) { this.sm._rejectCurrentPromise(e); } } else { if (this._doneTransition) { this._doneTransition.trigger(null); } } // We evaluate this state's transitions' onEntry methods // There is a chance that we are not current anymore if (this._isCurrent) { for (let trans of this.transitions) { trans._enter(); } } }); } _exit(transition) { this._isExiting = true; // Here we unsubscribe from all events this._events.forEach(eventCont => { this.sm.removeListener(eventCont.event, eventCont.handler); }); this._typedEvents.forEach(eventCont => { eventCont.event.removeListener(eventCont.handler); }); let retPromise; if (this.onExit) { try { const ret = this.onExit(transition); if (ret instanceof Promise) { retPromise = ret; } } catch (e) { this.sm._rejectCurrentPromise(e); } } for (let trans of this.transitions) { trans._exit(); } let exitCleanup = () => { this._exitCounter++; this._isExiting = false; this._isCurrent = false; }; if (retPromise) { return retPromise .then(exitCleanup) .catch(e => this.sm._rejectCurrentPromise(e)); } else { exitCleanup(); } } _stop() { let retPromise; this.transitions.forEach(trans => { if (trans instanceof TimeoutTransition_1.TimeoutTransition) { trans.stop(); } }); // First we call onStop if it is defined if (this.onStop) { try { const ret = this.onStop(); if (ret instanceof Promise) { retPromise = ret; } } catch (e) { this.sm._rejectCurrentPromise(e); } } if (!this._isExiting && this._exitCounter !== this._entryCounter) { // We also call exit const finalTransition = new Transition_1.Transition('FINAL TRANSITON', null); finalTransition._setSourceState(this); const callExit = () => { let exitRet; try { exitRet = this._exit(finalTransition); } catch (e) { this.sm._rejectCurrentPromise(e); } return exitRet; }; // If we have a stop promise we chain this to that if (retPromise) { retPromise = retPromise.then(() => callExit()); } else { // Otherwise we just call Exit let exitRet = callExit(); if (exitRet instanceof Promise) { retPromise = exitRet; } } } if (retPromise instanceof Promise) { return retPromise.then(() => undefined); } } /** * Retrieves the last result that was provided to this state upon entry * @returns {Object} */ getLastIncomingResult() { return this._lastIncomingResults; } /** * Returns whether this is the current state of the state machine * @return {boolean} */ isCurrent() { return this._isCurrent; } transitionTo(arg1, arg2, arg3) { let destState; let name; let result; // If we are being given a transition name if (typeof arg1 === 'string') { name = arg1; if (arg2 instanceof State) { destState = arg2; result = arg3; } else { result = arg2; } } else if (arg1 instanceof State) { destState = arg1; result = arg2; } // Here we find the internal transition that matches const results = this._internals.filter(i => { return ((name === undefined) || (name === i.name)) && ((destState === undefined) || (destState === i.destState)); }); const destStateName = destState ? destState.name : 'unknown'; if (results.length === 0) { throw new Error(`State '${this.name}': no internal transition of name '${name}' to state '${destStateName}' found`); } else if (results.length > 1) { throw new Error(`State '${this.name}': has more than one matching internal transition: '${name}' to state '${destStateName}'`); } else { const internal = results[0]; const internalDestStateName = internal.destState ? internal.destState.name : 'unknown'; const internalTransName = internal.transition ? internal.transition.name : 'unknown'; if (!this._isCurrent) { let warn = `Attempting internal transition out of a non-current state. ` + `This state: '${this.name}', target state: '${internalDestStateName}', ` + `transition name: '${internalTransName}'. `; if (this.sm.current) { warn += `Current state: '${this.sm.current.name}'.`; } else { warn += `The state machine is currently not running.`; } log.warn(warn); } else { internal.transition.trigger(result); } } } /** * Destroys the state. Should be called when state machine is getting discarded */ destroy() { // this._isCurrent = false; this._events.forEach(eventContainer => { this.sm.removeListener(eventContainer.event, eventContainer.handler); }); this._events.clear(); this._typedEvents.forEach(eventContainer => { eventContainer.event.removeListener(eventContainer.handler); }); this._typedEvents.clear(); this.transitions.forEach(trans => trans.destroy()); // this.transitions = []; // this._internalTransitions.clear(); this._lastIncomingResults = null; this.onEntry = null; this.onExit = null; this.onUpdate = null; this.onStop = null; } toString() { return 'State: ' + this.name; } } exports.State = State; // For unit tests State.prototype.log = log; },{"./TimeoutTransition":3,"./Transition":4,"./Utils":5,"./log":6}],2:[function(require,module,exports){ "use strict"; var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { return new (P || (P = Promise))(function (resolve, reject) { function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } function step(result) { result.done ? resolve(result.value) : new P(function (resolve) { resolve(result.value); }).then(fulfilled, rejected); } step((generator = generator.apply(thisArg, _arguments || [])).next()); }); }; Object.defineProperty(exports, "__esModule", { value: true }); const events = require("events"); const fs = require("fs"); const jibo_typed_events_1 = require("jibo-typed-events"); const Transition_1 = require("./Transition"); const Utils_1 = require("./Utils"); const log_1 = require("./log"); const log = log_1.default.createChild("StateMachine"); class StateMachine extends events.EventEmitter { constructor() { super(); this.states = new Map(); this.stateChanged = new jibo_typed_events_1.Event(`State transitioned`); this._globalEvents = new Map(); this._globalTypedEvents = new Map(); this._inTransition = false; this._loopDetection = new Map(); this._isStopped = false; this._traceIndex = 0; this._trace = []; this._cleanupAll(); } /** * Adds a global event that will transition out of any state into a destination state * @param {string} event - Name of event * @param {State} destState - State to transition to * @param {State[]} [exceptionStates] - States that this global transition does NOT apply to * @returns {this} * @memberof StateMachine */ addGlobalEventTransition(event, destState, exceptionStates = []) { if (this._globalEvents.has(event)) { throw new Error(`Duplicate global event transition. Event: '${event}'`); } this._globalEvents.set(event, { event: event, destination: destState, exceptions: exceptionStates }); // Add to all existing states this.states.forEach(state => { if (state !== destState && exceptionStates.indexOf(state) === -1) { state._addEventTransition(event, destState, true); } }); return this; } /** * Adds a global event that will transition out of any state into a destination state * @param {Event} event - Typed event * @param {State} destState - State to transition to * @param {State[]} [exceptionStates] - States that this global transition does NOT apply to * @returns {this} * @memberof StateMachine */ addGlobalTypedEventTransition(event, destState, exceptionStates = []) { if (this._globalTypedEvents.has(event)) { throw new Error(`Duplicate global typed event transition. Event: '${event.name}'`); } this._globalTypedEvents.set(event, { event: event, destination: destState, exceptions: exceptionStates }); // Add to all existing states this.states.forEach(state => { if (state !== destState && exceptionStates.indexOf(state) === -1) { state._addTypedEventTransition(event, destState, true); } }); return this; } /** * Rejects currently running start promise * @param {string|Error} error * @private */ _rejectCurrentPromise(error) { return __awaiter(this, void 0, void 0, function* () { if (this._promiseReject) { this._promiseReject(error); } else { log.warn(`Reject being called when SM isn't running, with error: `, error); } }); } /** * Resoles currently running start promise * @param {object} data * @private */ _resolveCurrentPromise(data) { if (this._promiseResolve) { this._promiseResolve(data); } else { log.warn(`Resolve called when SM isn't running, with data: `, data); } } /** * Destroys the state machine and all of its states. * Should be called when state machine is getting discarded */ destroy() { const destroy = () => { this._globalEvents.clear(); this._globalTypedEvents.clear(); this.states.forEach(state => { state.destroy(); }); this.states.clear(); this._cleanupAll(); }; if (this._currentRunPromise) { return this.stop().then(destroy); } else { return Promise.resolve().then(destroy); } } /** * Returns current state of state machine * @returns {State} */ getCurrentState() { return this.current; } /** * Sets the initial state of the state machine * @param state * @returns {StateMachine} */ setInitial(state) { this.initial = state; return this; } /** * Starts the state machine. This is required to be called once. * @param {Object} [input] - Optional input data * @returns {Promise} resolved when any state throws an error or calls this.exit(); */ start(input) { return __awaiter(this, void 0, void 0, function* () { if (!this.initial) { return Promise.reject(new Error(`State machine does not yet have an initial state`)); } else if (this._currentRunPromise) { return Promise.reject(new Error(`State machine already running, please call stop first`)); } this._currentRunPromise = new Promise((resolve, reject) => { this._promiseResolve = resolve; this._promiseReject = reject; }).then((data) => { this._cleanupAfterStart(); return data; }).catch((e) => __awaiter(this, void 0, void 0, function* () { try { yield this.stop(); } catch (stopError) { log.error(`Error while stopping state machine because of an earlier error`); log.error(stopError); } finally { this._cleanupAfterStart(); throw e; } })); this._cleanupBeforeStart(); this.current = this.initial; this.current._isCurrent = true; let initialTransition = new Transition_1.Transition('INITIAL TRANSITON', this.initial); this._addToTrace(initialTransition); this.current._enter(initialTransition, input); return this._currentRunPromise; }); } /** * Informs whether the state machine is currently running * @return {boolean} True if the state machine is currently running */ isRunning() { return (!!this._currentRunPromise); } /** * Waits until state machine is finished. If it isn't running, it resolves promise immediately * @return {Promise} */ waitUntilFinished() { if (this._currentRunPromise) { return this._currentRunPromise; } else { return Promise.resolve(); } } _addState(state) { if (this.states.has(state.name)) { this._rejectCurrentPromise(new Error(`State with name '${state.name}' already exists`)); return; } if (!this.initial) { this.setInitial(state); } // Here we add all current global transitions to this state this._globalEvents.forEach(event => { if (event.destination !== state && event.exceptions.indexOf(state) === -1) { state._addEventTransition(event.event, event.destination, true); } }); this._globalTypedEvents.forEach(event => { if (event.destination !== state && event.exceptions.indexOf(state) === -1) { state._addTypedEventTransition(event.event, event.destination, true); } }); this.states.set(state.name, state); return this; } /** * Get state by name * @param {string} name * @returns {State} */ getState(name) { return this.states.get(name); } _addToTrace(transition) { const te = { transition: transition, timestamp: Date.now(), }; if (this._trace.length < StateMachine.TRACE_MAX_LENGTH) { this._trace.push(te); } else { this._trace[this._traceIndex] = te; this._traceIndex = (this._traceIndex + 1) % StateMachine.TRACE_MAX_LENGTH; } } /** * Updates the current state, if it has an onUpdate callback. * Normal functionality of the StateMachine does not require that you call this, only if your * state should be connected to an update loop. */ update() { if (this.current && this.current.onUpdate) { this.current.onUpdate(); } } /** * Stops the current state (calls its onStop callback). * @returns {Promise} resolved when stop is complete */ stop(data) { // If we have a current run instance if (this._currentRunPromise) { this._isStopped = true; if (this.current) { this._isStopped = true; const stopRet = this.current._stop(); // We wait for the exit promise to complete if it exists const exitWait = this._currentStateExitPromise || Promise.resolve(); if (stopRet instanceof Promise) { return exitWait.then(() => stopRet).then(() => { this.current = null; this._resolveCurrentPromise(data); }); } else { return exitWait.then(() => { this.current = null; this._resolveCurrentPromise(data); }); } } else { return Promise.resolve(); } } else { // Stop being called but SM wasn't running return Promise.resolve(); } } /** * Returns the trace of transitions leading up to the current one (limited * by StateMachine.TRACE_MAX_LENGTH) * @returns {TraceElement[]} */ getTrace() { let out = []; if (this._trace.length < StateMachine.TRACE_MAX_LENGTH) { for (let i = 0; i < this._trace.length; i++) { out.push(this._trace[i]); } } else { for (let i = 0; i < this._trace.length; i++) { let ind = (this._traceIndex + i) % StateMachine.TRACE_MAX_LENGTH; out.push(this._trace[ind]); } } return out; } /** * Provides a convenient string representation of getTrace() * @returns {string} */ traceToString() { let trace = this.getTrace(); let str = ''; trace.forEach(t => { let sName = t.transition.getSourceState() ? t.transition.getSourceState().name : 'none'; str += `Source: '${sName}' -> Transition: '${t.transition.name}' @ ${t.timestamp} ` + `-> Destination: '${t.transition.getDestinationState().name}' \n`; }); return str; } /** * Transitions using a particular transition * @param transition * @param result * @private */ _transitionTo(transition, result) { if (this._isStopped) { return; } // if the state machine is stopped, do nothing if (!this.current) { return; } // See if this is the first in sequence of continuous transitions let isThisFirstTransition = !this._inTransition; if (isThisFirstTransition) { this._inTransition = true; } // Here we look for loops let destName = transition.getDestinationState().name; let loopCounter = this._loopDetection.get(destName); if (!loopCounter) { this._loopDetection.set(destName, 1); } else if (loopCounter > StateMachine.MAX_LOOPS) { return this._rejectCurrentPromise(new Error(`State machine has looped '${loopCounter}' times in ` + `one transition. Next state: '${destName}'`)); } else { this._loopDetection.set(destName, loopCounter + 1); } this._addToTrace(transition); if (transition.getSourceState() !== this.current) { this._rejectCurrentPromise(new Error(`Can't apply transition '${transition}' since the ` + `current state is '${this.current}'`)); } // Notify current state and transitions of exit const exitDone = () => { this._currentStateExitPromise = null; if (!this._isStopped) { // Notify next state and transitions of entry this.current = transition.getDestinationState(); this.current._enter(transition, result); // When leaving the first transition in a sequence, we clear our loop // detection mechanism if (isThisFirstTransition) { this._inTransition = false; this._loopDetection.clear(); } } }; const exitRet = this.current._exit(transition); if (exitRet instanceof Promise) { this._currentStateExitPromise = exitRet; return exitRet.then(exitDone).catch(e => this._rejectCurrentPromise(e)); } else { exitDone(); } } /** * Produces a .dot file from current state graph * Writes dotfile to disk at filepath * @param {string} filePath * @returns {string} */ toDotFile(filePath) { const regBgColor = "lightgrey"; const globalBgColor = "#a0a0a0"; let lines = ['digraph graphname {']; this.states.forEach(state => { let name = state.name.replace(/\"/ig, '\\"'); let lineColor = (state === this.initial) ? 'black' : regBgColor; lines.push(`"${name}" [style=filled,fillcolor="${regBgColor}",color="${lineColor}"];`); }); // Here we handle all global transitions if (this._globalEvents.size > 0 || this._globalTypedEvents.size > 0) { let globalName = "Any state"; lines.push(`"${globalName}" [shape=box,style=filled,fillcolor="${globalBgColor}",color=black];`); this._globalEvents.forEach(event => { let targetName = event.destination.name.replace(/\"/ig, '\\"'); let transitionName = Utils_1.createEventTransitionName(event.event).replace(/\"/ig, '\\"'); lines.push(` "${globalName}" -> "${targetName}" [label = "${transitionName}"];`); }); this._globalTypedEvents.forEach(event => { let targetName = event.destination.name.replace(/\"/ig, '\\"'); let transitionName = Utils_1.createTypedEventTransitionName(event.event).replace(/\"/ig, '\\"'); lines.push(` "${globalName}" -> "${targetName}" [label = "${transitionName}"];`); }); } // Here we handle all non-global transitions this.states.forEach(state => { state.transitions.forEach((transition) => { if (!transition._isGlobal) { let sourceStateName = state.name.replace(/\"/ig, '\\"'); let targetName = transition.getDestinationState().name.replace(/\"/ig, '\\"'); let transitionName = transition.name.replace(/\"/ig, '\\"'); lines.push(` "${sourceStateName}" -> "${targetName}" [label = "${transitionName}"];`); } }); }); lines.push('}'); const str = lines.join('\n'); if (filePath) { fs.writeFileSync(filePath, str, { encoding: 'utf8' }); } return str; } _cleanupAll() { this._cleanupBeforeStart(); this._cleanupAfterStart(); } _cleanupAfterStart() { this._isStopped = false; this._currentRunPromise = null; this._currentStateExitPromise = null; this._promiseResolve = null; this._promiseReject = null; this._inTransition = false; } _cleanupBeforeStart() { this._isStopped = false; this._inTransition = false; this._traceIndex = 0; this._trace = []; this._loopDetection.clear(); } } StateMachine.TRACE_MAX_LENGTH = 20; StateMachine.MAX_LOOPS = 5; exports.StateMachine = StateMachine; },{"./Transition":4,"./Utils":5,"./log":6,"events":undefined,"fs":undefined,"jibo-typed-events":undefined}],3:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); const Transition_1 = require("./Transition"); class TimeoutTransition extends Transition_1.Transition { constructor(name, destState, timeMs) { super(name, destState); this.onEntry = () => { this._timeoutHandle = setTimeout(() => { this._timeoutHandle = null; this.trigger(); }, timeMs); }; this.onExit = () => { if (this._timeoutHandle) { clearTimeout(this._timeoutHandle); this._timeoutHandle = null; } }; } stop() { this.onExit(); } } exports.TimeoutTransition = TimeoutTransition; },{"./Transition":4}],4:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); class Transition { constructor(name, _destState) { this.name = name; this._destState = _destState; // Is only used for global states and only matters for dot visualization this._isGlobal = false; } /** * Called to trigger the current transition * @param {object} [result] to pass to next state */ trigger(result) { if (this._sourceState) { this._sourceState.sm._transitionTo(this, result); } } _enter() { if (this.onEntry) { this.onEntry(); } } _exit() { if (this.onExit) { this.onExit(); } } /** * The source state for this transition * @returns {State} */ getSourceState() { return this._sourceState; } /** * The destination for this transition * @returns {State} */ getDestinationState() { return this._destState; } /** * Sets the source state, should only be called when adding transition to * the source state. * Can only be called once per transition * @param sourceState * @private */ _setSourceState(sourceState) { if (this._sourceState) { this._sourceState.sm._rejectCurrentPromise(new Error(`Transition '${this}' already has source state`)); } this._sourceState = sourceState; } /** * Destroys the transition. Should be called when state machine is getting discarded */ destroy() { this.onEntry = null; this.onExit = null; this._sourceState = null; } toString() { return this.name; } } exports.Transition = Transition; },{}],5:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); function createTypedEventTransitionName(event) { return 'TEvent: ' + event.name; } exports.createTypedEventTransitionName = createTypedEventTransitionName; function createEventTransitionName(event) { return 'Event: ' + event; } exports.createEventTransitionName = createEventTransitionName; },{}],6:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); const jibo_log_1 = require("jibo-log"); exports.default = new jibo_log_1.Log('StateMachine'); },{"jibo-log":undefined}],7:[function(require,module,exports){ "use strict"; function __export(m) { for (var p in m) if (!exports.hasOwnProperty(p)) exports[p] = m[p]; } Object.defineProperty(exports, "__esModule", { value: true }); __export(require("./StateMachine")); __export(require("./Transition")); __export(require("./State")); __export(require("./TimeoutTransition")); },{"./State":1,"./StateMachine":2,"./TimeoutTransition":3,"./Transition":4}]},{},[7])(7) }); //# sourceMappingURL=jibo-state-machine.js.map