node-red-contrib-xstate-machine
Version:
Xstate-based state machine implementation using state-machine-cat visualization for node red.
670 lines (587 loc) • 19.3 kB
JavaScript
module.exports = function (RED) {
"use strict";
var vm = require("vm");
var util = require("util");
var xstate = require('xstate');
var smcat = require('../src/smcat-render');
var immutable = require('immutable');
xstate.smcat = require('../src/xstate-smcat');
RED.smxstate = {};
RED.smxstate.settings = require('../src/smxstate-settings');
var registeredNodeIDs = [];
var activeId = null;
function sendWrapper(node, sendFcn, _msgid, msgArr, cloneMsg) {
// Copied from function node
if (msgArr == null) {
return;
} else if (!util.isArray(msgArr)) {
msgArr = [msgArr];
}
var msgCount = 0;
// We only have one msg output (2nd output), ignore all the others
if (!util.isArray(msgArr[0])) {
msgArr[0] = [msgArr[0]];
}
// shallow clone the msgArr because we may delete some elements
msgArr[0] = [...msgArr[0]];
msgArr.splice(1);
for (let n=0; n < msgArr[0].length; n++) {
let msg = msgArr[0][n];
if (msg !== null && msg !== undefined) {
if (typeof msg === 'object' && !Buffer.isBuffer(msg) && !util.isArray(msg)) {
if (msgCount === 0 && cloneMsg !== false) {
msgArr[0][n] = RED.util.cloneMessage(msgArr[0][n]);
msg = msgArr[0][n];
}
msg._msgid = _msgid;
msgCount++;
} else {
let type = typeof msg;
if (type === 'object') {
type = Buffer.isBuffer(msg)?'Buffer':(util.isArray(msg)?'Array':'Date');
}
node.error("Trying to send invalid message type.");
}
}
}
if (msgCount>0) {
// Send to 2nd output
sendFcn.call(node,[null, ...msgArr]);
}
}
function getSandbox(node) {
var sandbox = {
console:console,
util:util,
Buffer:Buffer,
Date: Date,
xstate: {
Machine: xstate.Machine,
assign: xstate.assign,
actions: xstate.actions,
sendUpdate: xstate.sendUpdate,
spawn: xstate.spawn,
after: xstate.after,
State: xstate.State,
interpret: xstate.interpret,
send: xstate.send,
sendParent: xstate.sendParent,
raise: xstate.raise
},
RED: {
util: RED.util
},
__node__: {
id: node.id,
name: node.name,
log: function() {
node.log.apply(node, arguments);
},
error: function() {
node.error.apply(node, arguments);
},
warn: function() {
node.warn.apply(node, arguments);
},
debug: function() {
node.debug.apply(node, arguments);
},
trace: function() {
node.trace.apply(node, arguments);
},
send: function(send, id, msgs, cloneMsg) {
sendWrapper(node, send, id, msgs, cloneMsg);
},
},
context: {
set: function() {
node.context().set.apply(node,arguments);
},
get: function() {
return node.context().get.apply(node,arguments);
},
keys: function() {
return node.context().keys.apply(node,arguments);
},
get global() {
return node.context().global;
},
get flow() {
return node.context().flow;
}
},
flow: {
set: function() {
node.context().flow.set.apply(node,arguments);
},
get: function() {
return node.context().flow.get.apply(node,arguments);
},
keys: function() {
return node.context().flow.keys.apply(node,arguments);
}
},
global: {
set: function() {
node.context().global.set.apply(node,arguments);
},
get: function() {
return node.context().global.get.apply(node,arguments);
},
keys: function() {
return node.context().global.keys.apply(node,arguments);
}
},
env: {
get: function(envVar) {
var flow = node._flow;
return flow.getSetting(envVar);
}
},
setTimeout: function () {
var func = arguments[0];
var timerId;
arguments[0] = function() {
sandbox.clearTimeout(timerId);
try {
func.apply(this,arguments);
} catch(err) {
node.error(err,{});
}
};
timerId = setTimeout.apply(this,arguments);
node.outstandingTimers.push(timerId);
return timerId;
},
clearTimeout: function(id) {
clearTimeout(id);
var index = node.outstandingTimers.indexOf(id);
if (index > -1) {
node.outstandingTimers.splice(index,1);
}
},
setInterval: function() {
var func = arguments[0];
var timerId;
arguments[0] = function() {
try {
func.apply(this,arguments);
} catch(err) {
node.error(err,{});
}
};
timerId = setInterval.apply(this,arguments);
node.outstandingIntervals.push(timerId);
return timerId;
},
clearInterval: function(id) {
clearInterval(id);
var index = node.outstandingIntervals.indexOf(id);
if (index > -1) {
node.outstandingIntervals.splice(index,1);
}
}
};
return sandbox;
}
function getFunctionText(node) {
return `
var result = null;
result = (async function(__send__,__done__){
var node = {
id:__node__.id,
name:__node__.name,
log:__node__.log,
warn:__node__.warn,
error:__node__.error,
debug:__node__.debug,
trace:__node__.trace,
status:__node__.status,
send:function(msgs,cloneMsg){__node__.send(__send__,RED.util.generateId(),msgs,cloneMsg);},
done:__done__
}
${node.config.xstateDefinition}
})(send,done);
`
}
function getXStateClock(node) {
return {
setTimeout: (fn, timeout) => {
// Trap exceptions
var fnHook = () => {
try {
fn.apply(null);
} catch(err) {
node.error(`Error during delayed event: ${err}`);
// ? TODO: halt statemachine
}
}
return setTimeout(fnHook,timeout);
},
clearTimeout: (id) => {
return clearTimeout(id);
}
};
}
function makeStateObject(state) {
return {
state: state.value,
changed: state.changed,
done: state.done,
event: state.event,
context: state.context
}
}
function restartMachine(node) {
if( !node ) return;
let context = node.context();
if( !context || !context.xstate || !context.xstate.blueprint ) return;
let service = context.xstate.service;
if( service )
service.stop();
let machine;
try {
machine = xstate.createMachine(
context.xstate.blueprint.toJS(),
context.xstate.machineConfig ? context.xstate.machineConfig : undefined);
service = xstate.interpret(machine, {
clock: context.xstate.clock
});
} catch(err) {
setErrorStatus(node);
node.error(err);
return;
}
context.xstate.service = service;
context.xstate.machine = machine;
let transitionFcn = (state) => {
node.status({fill: 'green', shape: 'dot', text: 'state: ' + JSON.stringify(state.value)});
let payload = {
state: state.value,
changed: state.changed,
done: state.done,
activities: state.activities,
actions: state.actions,
event: state.event,
context: state.context
};
try {
if( context.xstate.listeners ) {
let listeners = context.xstate.listeners;
if( Array.isArray(listeners) ) {
for( let listener of listeners ) {
listener(payload);
}
} else {
listeners(payload);
}
}
} catch(err) {
node.error(`Error while executing listeners: ${err}`);
}
// Output
node.send([[{
topic: "state",
payload: payload
}]]);
// Publish to editor
// Runtime only sends data if there are client connections/subscriptions
if( activeId == node.id ) {
RED.comms.publish("smxstate_transition",{
type: 'transition',
id: node.id,
state: makeStateObject(state),
machineId: context.xstate.blueprint.get('id')
});
}
};
let dataChangedFcn = (context, previousContext) => {
// Output
node.send([[{
topic: "context",
payload: context
}]]);
// Publish to editor
// Runtime only sends data if there are client connections/subscriptions
if( activeId == node.id ) {
RED.comms.publish("smxstate_transition",{
type: 'context',
id: node.id,
context: context,
machineId: node.context().xstate.blueprint.get('id')
});
}
};
service
.onTransition( (state) => transitionFcn(state) )
.onChange( (context, previousContext) => { if( context !== previousContext ) dataChangedFcn(context, previousContext); } );
try {
service.start();
} catch(err) {
setErrorStatus(node);
node.error(err);
}
}
function getNodeParentPath(node) {
let id = node.id;
let path = [];
let subflowInst = [], subflowProt = [];
let f = node._flow;
path.unshift((node.name ? node.name : node.type) + ` (${node._alias ? node._alias : node.id})`);
while( f.TYPE === "subflow" ) {
id = f.id;
subflowProt.unshift( f.subflowDef.id );
subflowInst.unshift( f.subflowInstance.id );
let subflowName = f.subflowInstance.name ? f.subflowInstance.name : f.subflowDef.name;
if( f.parent !== "subflow" ) {
path.unshift( subflowName + ` (${f.subflowInstance.id})`);
} else {
path.unshift( subflowName + ` (${f.subflowDef.id})`);
}
f = f.parent;
}
path.unshift(f.flow.label)
return {
rootId: id,
flowId: f.id,
path: {
labels: path,
subflowInstance: subflowInst,
subflowPrototype: subflowProt
}
};
}
function setErrorStatus(node) {
node.status({ fill: 'red', shape: 'ring', text: 'invalid setup' });
}
function StateMachineNode (config) {
RED.nodes.createNode(this, config);
// Keep track of node ids
let nodePath = getNodeParentPath(this);
let nodeinfo = Object.assign({
id: this.id, // ID of the instance of the node
name: this.name,
alias: this._alias, // This identifies the node prototype within subflows
}, nodePath);
registeredNodeIDs.push(nodeinfo);
var node = this;
var nodeContext = this.context();
// array of active timers
this.outstandingIntervals = [];
this.outstandingTimers = [];
// init the node status
setErrorStatus(node);
node.config = config;
// Send new node info to the UI
RED.comms.publish('smxstate', { type: 'add', data: nodeinfo });
// Create xstate state-machine
var vmcontext = vm.createContext(getSandbox(this));
vmcontext.send = node.send;
vmcontext.done = node.done;
try {
this.initscript = vm.createScript(getFunctionText(this), {
filename: 'SMXSTATE node:'+this.id+(this.name?' ['+this.name+']':''),
displayErrors: true
});
let promise = this.initscript.runInContext(vmcontext);
// Check for the variable names 'machine', 'config', and 'listeners'
// in the context of the vm after executing the init script. These
// are saved in the node's context and passed to the xstate interpreter.
// If 'machine' is not present assume that the script just returns an
// object containing the whole machine definition.
promise.then((result) => {
let smobj, smcfg, smlisteners;
if( result.hasOwnProperty('machine') ) {
smobj = result.machine;
if( result.hasOwnProperty('config') ) {
smcfg = result.config;
}
if( result.hasOwnProperty('listeners') ) {
smlisteners = result.listeners;
}
} else {
smobj = result;
}
// Set machine id to node id by default
if( !smobj.hasOwnProperty("id") ) {
smobj.id = node.id;
}
smobj.id = smobj.id.replace(/[^a-zA-Z0-9\.\s\n\r]/gi,'');
nodeContext.xstate = {
blueprint: immutable.fromJS(smobj),
machineConfig: smcfg,
listeners: smlisteners,
clock: getXStateClock(node)
};
restartMachine(node);
}).catch((err) => {
this.error(err);
});
} catch(err) {
this.error(err);
}
node.on('input', function (msg, send, done) {
let hasDone = ( done ? typeof done === "function" : false );
try {
if( msg.hasOwnProperty("topic") && typeof msg.topic === "string" ) {
if( msg.topic === "reset" ) {
restartMachine( node );
} else {
nodeContext.xstate.service.send(msg.topic, { payload: msg.payload });
}
}
else
throw( "No event (msg.topic) is defined." );
if( hasDone ) done();
} catch(err) {
if( hasDone ) done(err);
else node.error(err);
}
});
node.on('close', function (removed, done) {
// Removing a node within a subflow or removing a subflow containing this node does not set removed to true!
RED.comms.publish('smxstate', {
type: 'delete',
removed: removed,
data: registeredNodeIDs.filter(e => e.id === this.id )
}
);
nodeContext.xstate.service.stop();
registeredNodeIDs = registeredNodeIDs.filter(e => e.id != this.id);
if( done && typeof done === "function" ) done();
});
}
RED.nodes.registerType('smxstate', StateMachineNode);
// Do one time init tasks
smcat.init(RED);
// Initialize settings
RED.smxstate.settings.init(RED);
RED.httpAdmin.get("/smxstate/:method", RED.auth.needsPermission("smxstate.read"), function(req,res) {
switch(req.params.method) {
case 'getnodes':
try {
res.status(200).send(registeredNodeIDs);
} catch(err) {
res.sendStatus(500);
console.error(`smxstate: GET Command failed: ${err.toString()}`);
}
break;
case 'settings':
try {
let property = req.query.property;
let value = RED.smxstate.settings.get(property);
if( value !== null )
res.status(200).send({ [property]: value });
else {
res.sendStatus(404);
console.error(`smxstate: No setting named ${property} is available`);
}
} catch(err) {
res.sendStatus(500);
console.error(`smxstate: GET Command failed: ${err.toString()}`);
}
break;
default:
res.sendStatus(404);
console.error(`smxstate: Invalid method: ${req.params.method}`);
break;
}
});
RED.httpAdmin.post("/smxstate/:method", RED.auth.needsPermission("smxstate.write"), function(req,res) {
switch(req.params.method) {
case 'settings':
try {
RED.smxstate.settings.set(req.body.property, req.body.value ).then( () => {
res.status(200).send("success");
}).catch( (err) => { throw(err); });
} catch(err) {
res.sendStatus(500);
console.error(`smxstate: POST Command for method ${req.params.method} failed: ${err.toString()}`);
}
break;
default:
res.sendStatus(404);
console.error(`smxstate: Invalid method: ${req.params.method}`);
break;
}
});
RED.httpAdmin.post("/smxstate/:id/:method", RED.auth.needsPermission("smxstate.write"), function(req,res) {
var node = RED.nodes.getNode(req.params.id);
if (node != null) {
switch(req.params.method) {
case 'getgraph':
try {
// Render state machine using smcat
let smcat_machine = xstate.smcat.toSmcat(node.context().xstate.machine);
// Render in separate process with timeout
(async () => {
try {
let output = await smcat.render(smcat_machine, {
timeoutMs: Number.parseInt(RED.smxstate.settings.get('renderTimeoutMs')),
renderer: RED.smxstate.settings.get('renderer'),
logOutput: true,
forceRedraw: req.body.hasOwnProperty("forceRedraw") ? req.body.forceRedraw === "true" : false
});
let smcat_svg;
if( !!output && output.code === 0 ) {
smcat_svg = output.data;
} else {
res.sendStatus(500);
if( !!output )
node.error(`Rendering of state machine failed: Render process returned error code ${output.code}: ${output.err}`);
else
node.error(`Rendering of state machine failed: Render process timed out.`);
return;
}
smcat_svg = smcat_svg.match(/<svg.*?>.*?<\/svg>/si)[0];
res.status(200).send(smcat_svg);
// Send update for context/state
let context = node.context().xstate;
await util.promisify(setTimeout)(100);
RED.comms.publish("smxstate_transition",{
type: 'transition',
id: node.id,
state: makeStateObject(context.service.state),
machineId: context.blueprint.get('id')
});
await util.promisify(setTimeout)(100);
RED.comms.publish("smxstate_transition",{
type: 'context',
id: node.id,
context: context.service.state.context,
machineId: context.blueprint.get('id')
});
} catch(err) {
// Rendering was rejected
res.sendStatus(500);
node.error(`Rendering of state machine failed: Render process returned error: ${err}`);
}
})();
// Save the last provied graph ID
activeId = req.params.id;
//res.sendStatus(200);
} catch(err) {
res.sendStatus(500);
node.error(`Rendering of state machine failed: ${err.toString()}`);
}
break;
case 'reset':
try {
restartMachine(node);
res.sendStatus(200);
} catch(err) {
res.sendStatus(500);
node.error(`Reset of state machine failed: ${err.toString()}`);
}
break;
default:
res.sendStatus(404);
node.error(`Invalid method: ${req.params.method}`);
break;
}
} else {
console.error(`smxstate: node with id ${req.params.id} does not exist for method ${req.params.method}.`)
res.sendStatus(404);
}
});
};