diff --git a/packages/node_modules/@node-red/editor-client/src/js/ui/editors/code-editors/monaco.js b/packages/node_modules/@node-red/editor-client/src/js/ui/editors/code-editors/monaco.js index 0854debdd..fb6c98674 100644 --- a/packages/node_modules/@node-red/editor-client/src/js/ui/editors/code-editors/monaco.js +++ b/packages/node_modules/@node-red/editor-client/src/js/ui/editors/code-editors/monaco.js @@ -55,6 +55,7 @@ RED.editor.codeEditor.monaco = (function() { const type = "monaco"; const monacoThemes = ["vs","vs-dark","hc-black"]; //TODO: consider setting hc-black autmatically based on acessability? let userSelectedTheme; + let tsFuncData; //used to cache the original func.d.ts data for later use when updating linkcall targets //TODO: get from externalModules.js For now this is enough for feature parity with ACE (and then some). const knownModules = { @@ -143,6 +144,9 @@ RED.editor.codeEditor.monaco = (function() { var typePath = "types/" + libPath; $.get(typePath) .done(function(data) { + if (libPath === "node-red/func.d.ts") { + tsFuncData = data; //cache the original func.d.ts data for later use when updating linkcall targets + } modulesCache[libPath] = data; if(!preloadOnly) { loadedLibs.JS[libModule] = monaco.typescript.javascriptDefaults.addExtraLib(data, "file://types/" + libPackage + "/" + libModule + "/index.d.ts"); @@ -929,6 +933,63 @@ RED.editor.codeEditor.monaco = (function() { //check if extraLibs are to be loaded (e.g. fs or os) refreshModuleLibs(editorOptions.extraLibs) + // update func.d.ts definitions for up-to-date link-in target names in the node.linkcall() definition + if (tsFuncData && options.node && options.node.type === 'function') { + const getUpdatedLinkcallDefs = () => { + const linkInNodes = RED.nodes.filterNodes({type:'link in'}) + const viableLinkTargets = [] + for (const target of linkInNodes) { + // links on same flow/subflow as caller node are valid targets + // however links inside a (different) subflow are not valid targets. + if (target.z === options.node.z || !RED.nodes.subflow(target.z)) { + viableLinkTargets.push(target) + } + } + const targetsByName = [...new Set(viableLinkTargets.map(node => node.name || '').filter(name => name.length > 0))].map(s => JSON.stringify(s)); + const targetsById = [...new Set(viableLinkTargets.map(node => node.id))].map(s => JSON.stringify(s)); + const targets = [...targetsByName, ...targetsById].filter(s => s && s.length > 0).join("|") || 'string'; + return ` + // #region:linkcall + /** + * Utility function to call, a reusable flow defined as a subroutine (link-in ~ ~ link-out). + * When the \`linkcall\` function resolves, it returns the \`msg\` object returned by the link-out + * (return) node of the subroutine. + * + * @param {string} target - the name or ID of the target link-in subroutine to call + * @param {Object} msg - the message object to pass to the subroutine + * @param {Object} [options] - call options + * @param {number} [options.timeout=5000] - the maximum time to wait for a response (default: 5000ms) + * @param {boolean} [options.clone=true] - flag to indicate if the message should be cloned (default: true) + * @return {Promise} - resolves with the returned message + * + * @example Call "greeting-person" subroutine by name: + * \`\`\`javascript + * msg.payload = "Joe Bloggs"; + * const resultMsg = await node.linkcall("greeting-person", msg, {timeout: 10000}); + * msg.payload = resultMsg.payload; // payload = "Hello Joe Bloggs" + * return msg; // return updated msg + * \`\`\` + * + * @example Call "greeting-person" subroutine by node id: + * \`\`\`javascript + * msg.payload = "John Doe"; + * const result = await node.linkcall("a1b2c3d4e5f6", msg); // result.payload will be "Hello John Doe" + * return result; // return new result object + * \`\`\` + */ + static linkcall(target: ${targets}, msg: object, options?: { timeout?: number; clone?: boolean; [key: string]: any }): Promise; + // #endregion:linkcall +` + } + const newDefs = getUpdatedLinkcallDefs(); + data = tsFuncData.replace(/\/\/ #region:linkcall[\s\S]*?\/\/ #endregion:linkcall/g, newDefs); + if (loadedLibs.JS.func) { + loadedLibs.JS.func.dispose(); + loadedLibs.JS.func = null; + } + loadedLibs.JS.func = monaco.languages.typescript.javascriptDefaults.addExtraLib(data, 'file://types/node-red/func/index.d.ts'); + } + function refreshModuleLibs(extraModuleLibs) { var defs = []; var imports = []; diff --git a/packages/node_modules/@node-red/editor-client/src/types/node-red/func.d.ts b/packages/node_modules/@node-red/editor-client/src/types/node-red/func.d.ts index 50880a385..230dca26e 100644 --- a/packages/node_modules/@node-red/editor-client/src/types/node-red/func.d.ts +++ b/packages/node_modules/@node-red/editor-client/src/types/node-red/func.d.ts @@ -57,6 +57,36 @@ declare class node { * @see Node-RED documentation [writing-functions: adding-status](https://nodered.org/docs/user-guide/writing-functions#adding-status) */ static status(status:string|boolean|number); + // #region:linkcall + /** + * Utility function to call a reusable flow defined as a subroutine (link-in/link-out nodes). + * + * @param target - the target of the link-in subroutine to call (can be node ID or name of link-in node) + * @param {Object} msg - the message object to pass to the subroutine + * @param {Object} [options] - call options + * @param {number} [options.timeout=5000] - the maximum time to wait for a response (default: 5000ms) + * @param {boolean} [options.clone=true] - flag to indicate if the message should be cloned (default: true) + * + * @return {Promise} - resolves with the returned message + * + * @example Call "greeting-person" subroutine by name: + * ```javascript + * msg.payload = "Joe Bloggs"; + * const result = await node.linkcall("greeting-person", msg, {timeout: 10000}); + * msg.payload = result.payload; // payload = "Hello Joe Bloggs" + * return msg; // return updated msg + * ``` + * + * @example Call subroutine by node id: + * ```javascript + * msg.payload = "John Doe"; + * const result = await node.linkcall("a1b2c3d4e5f67890", msg); + * // result.payload will be "Hello John Doe" + * return result; // return new result object + * ``` + */ + static linkcall(target: string, msg: object, options?: { timeout?: number; clone?: boolean; [key: string]: any }): Promise; + // #endregion:linkcall /** the id of this node */ public static readonly id:string; /** the name of this node */ diff --git a/packages/node_modules/@node-red/editor-client/src/types/node-red/util.d.ts b/packages/node_modules/@node-red/editor-client/src/types/node-red/util.d.ts index 255243d47..ed81ff513 100644 --- a/packages/node_modules/@node-red/editor-client/src/types/node-red/util.d.ts +++ b/packages/node_modules/@node-red/editor-client/src/types/node-red/util.d.ts @@ -207,5 +207,16 @@ declare namespace RED { * @memberof @node-red/util_util */ function parseContextStore(key: string): any; + /** + * Utility function to call a reusable flow defined as a subroutine (link-in/link-out nodes). + * + * @param {string} target - the target of the link-in subroutine to call (can be the name or ID of a link-in node) + * @param {Object} msg - the message object to pass to the subroutine + * @param {Object} [options] - call options + * @param {number} [options.timeout=5000] - the maximum time to wait for a response (default: 5000ms) + * @return {Promise} - resolves with the returned message + * @memberof @node-red/util_util + */ + function linkcall(target: string, msg: object, options?: object): Promise; } } diff --git a/packages/node_modules/@node-red/nodes/core/common/60-link.js b/packages/node_modules/@node-red/nodes/core/common/60-link.js index f2c38b74c..60882d123 100644 --- a/packages/node_modules/@node-red/nodes/core/common/60-link.js +++ b/packages/node_modules/@node-red/nodes/core/common/60-link.js @@ -28,102 +28,6 @@ module.exports = function(RED) { "use strict"; const crypto = require("crypto"); - const targetCache = (function () { - let registry = { id: {}, name: {} } - function getIndex (/** @type {[LinkTarget]} */ targets, id) { - for (let index = 0; index < (targets || []).length; index++) { - const element = targets[index] - if (element.id === id) { - return index - } - } - return -1 - } - /** - * Generate a target object from a node - * @param {LinkInNode} node - * @returns {LinkTarget} a link target object - */ - function generateTarget (node) { - const isSubFlow = node._flow.TYPE === 'subflow' - return { - id: node.id, - name: node.name || node.id, - flowId: node._flow.flow.id, - flowName: isSubFlow ? node._flow.subflowDef.name : node._flow.flow.label, - isSubFlow: isSubFlow - } - } - return { - /** - * Get a list of targets registerd to this name - * @param {string} name Name of the target - * @param {boolean} [excludeSubflows] set `true` to exclude - * @returns {[LinkTarget]} Targets registerd to this name. - */ - getTargets (name, excludeSubflows) { - const targets = registry.name[name] || [] - if (excludeSubflows) { - return targets.filter(e => e.isSubFlow !== true) - } - return targets - }, - /** - * Get a single target by registered name. - * To restrict to a single flow, include the `flowId` - * If there is no targets OR more than one target, null is returned - * @param {string} name Name of the node - * @param {string} [flowId] - * @returns {LinkTarget} target - */ - getTarget (name, flowId) { - /** @type {[LinkTarget]} */ - let possibleTargets = this.getTargets(name) - /** @type {LinkTarget} */ - let target - if (possibleTargets.length && flowId) { - possibleTargets = possibleTargets.filter(e => e.flowId === flowId) - } - if (possibleTargets.length === 1) { - target = possibleTargets[0] - } - return target - }, - /** - * Get a target by node ID - * @param {string} nodeId ID of the node - * @returns {LinkTarget} target - */ - getTargetById (nodeId) { - return registry.id[nodeId] - }, - register (/** @type {LinkInNode} */ node) { - const target = generateTarget(node) - const tByName = this.getTarget(target.name, target.flowId) - if (!tByName || tByName.id !== target.id) { - registry.name[target.name] = registry.name[target.name] || [] - registry.name[target.name].push(target) - } - registry.id[target.id] = target - return target - }, - remove (node) { - const target = generateTarget(node) - const targs = this.getTargets(target.name) - const idx = getIndex(targs, target.id) - if (idx > -1) { - targs.splice(idx, 1) - } - if (targs.length === 0) { - delete registry.name[target.name] - } - delete registry.id[target.id] - }, - clear () { - registry = { id: {}, name: {} } - } - } - })() function LinkInNode(n) { RED.nodes.createNode(this,n); @@ -133,14 +37,14 @@ module.exports = function(RED) { msg._event = n.event; node.receive(msg); } - targetCache.register(node); + RED.nodes.linkcallTargets.register(node); RED.events.on(event,handler); this.on("input", function(msg, send, done) { send(msg); done(); }); this.on("close",function() { - targetCache.remove(node); + RED.nodes.linkcallTargets.remove(node); RED.events.removeListener(event,handler); }); } @@ -190,42 +94,12 @@ module.exports = function(RED) { if (isNaN(timeout)) { timeout = 30000; } - function getTargetNode(msg) { - const dynamicMode = linkType === "dynamic"; - const target = dynamicMode ? msg.target : staticTarget - ////1st see if the target is a direct node id - let foundNode; - if (targetCache.getTargetById(target)) { - foundNode = RED.nodes.getNode(target) - } - if (target && !foundNode && dynamicMode) { - //next, look in **this flow only** for the node - let cachedTarget = targetCache.getTarget(target, node._flow.flow.id); - if (!cachedTarget) { - //single target node not found in registry! - //get all possible targets from regular flows (exclude subflow instances) - const possibleTargets = targetCache.getTargets(target, true); - if (possibleTargets.length === 1) { - //only 1 link-in found with this name - good, lets use it - cachedTarget = possibleTargets[0]; - } else if (possibleTargets.length > 1) { - //more than 1 link-in has this name, raise an error - throw new Error(`Multiple link-in nodes named '${target}' found`); - } - } - if (cachedTarget) { - foundNode = RED.nodes.getNode(cachedTarget.id); - } - } - if (foundNode instanceof LinkInNode) { - return foundNode; - } - throw new Error(`target link-in node '${target || ""}' not found`); - } this.on("input", function (msg, send, done) { try { - const targetNode = getTargetNode(msg); + const dynamicMode = linkType === "dynamic"; + const target = dynamicMode ? msg.target : staticTarget + const targetNode = RED.nodes.linkcallTargets.getTargetNode(node, target, dynamicMode); if (targetNode instanceof LinkInNode) { msg._linkSource = msg._linkSource || []; const messageEvent = { @@ -282,5 +156,4 @@ module.exports = function(RED) { } RED.nodes.registerType("link call",LinkCallNode); - } diff --git a/packages/node_modules/@node-red/nodes/core/function/10-function.html b/packages/node_modules/@node-red/nodes/core/function/10-function.html index 58d77c88d..05d17c3a5 100644 --- a/packages/node_modules/@node-red/nodes/core/function/10-function.html +++ b/packages/node_modules/@node-red/nodes/core/function/10-function.html @@ -527,9 +527,15 @@ } }); - var buildEditor = function(id, stateId, focus, value, defaultValue, extraLibs, offset) { - var editor = RED.editor.createEditor({ + const buildEditor = function(id, node, focus, value, defaultValue, extraLibs, offset) { + const stateId = `${node.id}/${id}`; + const editor = RED.editor.createEditor({ id: id, + node: { + id: node.id, + type: node.type, + z: node.z + }, mode: 'ace/mode/nrjavascript', value: value || defaultValue || "", stateId: stateId, @@ -556,9 +562,9 @@ editor.__stateId = stateId; return editor; } - this.initEditor = buildEditor('node-input-init-editor', this.id + "/" + "initEditor", false, $("#node-input-initialize").val(), RED._("node-red:function.text.initialize"), undefined, 0); - this.editor = buildEditor('node-input-func-editor', this.id + "/" + "editor", true, $("#node-input-func").val(), undefined, that.libs || [], undefined, -1); - this.finalizeEditor = buildEditor('node-input-finalize-editor', this.id + "/" + "finalizeEditor", false, $("#node-input-finalize").val(), RED._("node-red:function.text.finalize"), undefined, 0); + this.initEditor = buildEditor('node-input-init-editor', this, false, $("#node-input-initialize").val(), RED._("node-red:function.text.initialize"), undefined, 0); + this.editor = buildEditor('node-input-func-editor', this, true, $("#node-input-func").val(), undefined, that.libs || [], undefined, -1); + this.finalizeEditor = buildEditor('node-input-finalize-editor', this, false, $("#node-input-finalize").val(), RED._("node-red:function.text.finalize"), undefined, 0); RED.library.create({ url:"functions", // where to get the data from diff --git a/packages/node_modules/@node-red/nodes/core/function/10-function.js b/packages/node_modules/@node-red/nodes/core/function/10-function.js index c0def92b3..7f804f280 100644 --- a/packages/node_modules/@node-red/nodes/core/function/10-function.js +++ b/packages/node_modules/@node-red/nodes/core/function/10-function.js @@ -93,6 +93,8 @@ module.exports = function(RED) { function FunctionNode(n) { RED.nodes.createNode(this,n); var node = this; + /** @type {vm.Script} */ + node.script = null; node.name = n.name; node.func = n.func; node.outputs = n.outputs; @@ -127,7 +129,8 @@ module.exports = function(RED) { "on:__node__.on,"+ "status:__node__.status,"+ "send:function(msgs,cloneMsg){ __node__.send(__send__,__msgid__,msgs,cloneMsg);},"+ - "done:__done__"+ + "done:__done__,"+ + "linkcall: __node__.linkcall"+ "};\n"+ node.func+"\n"+ "})(msg,__send__,__done__);"; @@ -158,6 +161,75 @@ module.exports = function(RED) { node.outstandingIntervals = []; node.clearStatus = false; + const crypto = require("crypto"); + const messageEvents = {}; + const timeoutMessage = (eventId) => { + const messageEvent = messageEvents[eventId]; + if (messageEvent) { + delete messageEvents[eventId]; + messageEvent.reject(new Error("timeout")); + } + }; + + node.returnLinkMessage = function(eventId, msg) { + if (Array.isArray(msg._linkSource) && msg._linkSource.length === 0) { + delete msg._linkSource; + } + const messageEvent = messageEvents[eventId]; + if (messageEvent) { + clearTimeout(messageEvent.ts); + delete messageEvents[eventId]; + messageEvent.resolve(msg); + } + } + + node.linkcall = async function (target, msg, options) { + try { + if (typeof target !== "string" || target.trim() === "") { + throw new Error(RED._("function.error.linkcallTargetInvalid")); + } + if (typeof msg !== "object" || msg === null) { + throw new Error(RED._("function.error.linkcallMsgInvalid")); + } + options = options || {} + options.timeout = options.timeout || 5000 // default timeout 5s + options.clone = options.clone === false ? false : true // default to cloning the message for safety + + const isDynamic = !RED.nodes.getNode(target) // if target is a node id, then its a static choice (not dynamic) + const targetNode = RED.nodes.linkcallTargets.getTargetNode(node, target, isDynamic) + if (!targetNode || targetNode.type !== 'link in') { + // getTargetNode should have already thrown an error if the target was not found, but in case it didn't for some reason, we throw here too. + throw new Error(RED._("function.error.linkcallTargetNotFound")); + } + + const m = options.clone === false ? msg : RED.util.cloneMessage(msg) + m._linkSource = m._linkSource || [] + const messageEvent = { + id: crypto.randomBytes(14).toString('hex'), + node: node.id, + } + messageEvents[messageEvent.id] = { + msg: m, + options: options, + send: sandbox.__send__, + done: sandbox.__done__, + ts: setTimeout(function () { + timeoutMessage(messageEvent.id) + }, options.timeout) + } + m._linkSource.push(messageEvent) + targetNode.receive(m) + return new Promise((resolve, reject) => { + messageEvents[messageEvent.id].resolve = resolve + messageEvents[messageEvent.id].reject = reject + }) + } catch (error) { + context + node.error(error, msg); + return null; + } + } + var sandbox = { console:console, util:util, @@ -170,7 +242,6 @@ module.exports = function(RED) { ...RED.util, getSetting: function (_node, name, _flow) { // Ensure `node` argument is the Function node and do not allow flow to be overridden. - return RED.util.getSetting(node, name); } } }, @@ -206,6 +277,9 @@ module.exports = function(RED) { status: function() { node.clearStatus = true; node.status.apply(node, arguments); + }, + linkcall: async function (msg, target, options) { + return node.linkcall.apply(node, arguments) } }, context: { diff --git a/packages/node_modules/@node-red/nodes/examples/common/link/03 - Link call.json b/packages/node_modules/@node-red/nodes/examples/common/link/03 - Link call.json index c2ab8bc3b..4fedd1bd5 100644 --- a/packages/node_modules/@node-red/nodes/examples/common/link/03 - Link call.json +++ b/packages/node_modules/@node-red/nodes/examples/common/link/03 - Link call.json @@ -1,59 +1,28 @@ [ { - "id": "62ea32aa.d73aac", + "id": "0364d0b105293afc", "type": "comment", - "z": "6312c0588348b2d4", - "name": "Example: Link Call Node", - "info": "Link call node can call link in node then get result from link out node.", - "x": 230, - "y": 180, + "z": "2cdc739225d6a4e8", + "name": "Example: Link Call", + "info": "### Link call node\nThe `link call` node can call a `link in` node then get result from `link out` node.\nIt can be set to a fixed target or passed a `target` in the `msg` for dynamic calling.\n\n### Function link call\nA `link in` node can be called from a function using `node.linkcall`\n", + "x": 170, + "y": 60, "wires": [] }, { - "id": "c588bc36.87fec", + "id": "f0e17fb6266e3e46", "type": "comment", - "z": "6312c0588348b2d4", - "name": "↓ call link in node", + "z": "2cdc739225d6a4e8", + "name": "↓ call link (fixed target)", "info": "", - "x": 440, - "y": 220, + "x": 420, + "y": 120, "wires": [] }, { - "id": "cd31efb4d2c6967e", - "type": "link call", - "z": "6312c0588348b2d4", - "name": "", - "links": [ - "dbc46892c8d14c37" - ], - "timeout": "30", - "x": 420, - "y": 260, - "wires": [ - [ - "c3db64d1d2260340" - ] - ] - }, - { - "id": "dbc46892c8d14c37", - "type": "link in", - "z": "6312c0588348b2d4", - "name": "", - "links": [], - "x": 315, - "y": 340, - "wires": [ - [ - "e10575d73f2e5352" - ] - ] - }, - { - "id": "6b61792143b3b0a3", + "id": "2869c5a483bad49f", "type": "inject", - "z": "6312c0588348b2d4", + "z": "2cdc739225d6a4e8", "name": "", "props": [ { @@ -71,56 +40,18 @@ "topic": "", "payload": "", "payloadType": "date", - "x": 240, - "y": 260, + "x": 200, + "y": 160, "wires": [ [ - "cd31efb4d2c6967e" + "06a8cdd201630b4c" ] ] }, { - "id": "e10575d73f2e5352", - "type": "change", - "z": "6312c0588348b2d4", - "name": "", - "rules": [ - { - "t": "set", - "p": "payload", - "pt": "msg", - "to": "Hello, World!", - "tot": "str" - } - ], - "action": "", - "property": "", - "from": "", - "to": "", - "reg": false, - "x": 450, - "y": 340, - "wires": [ - [ - "cf8438e7137bc0f0" - ] - ] - }, - { - "id": "cf8438e7137bc0f0", - "type": "link out", - "z": "6312c0588348b2d4", - "name": "", - "mode": "return", - "links": [], - "x": 595, - "y": 340, - "wires": [] - }, - { - "id": "c3db64d1d2260340", + "id": "943afa6102c26e24", "type": "debug", - "z": "6312c0588348b2d4", + "z": "2cdc739225d6a4e8", "name": "", "active": true, "tosidebar": true, @@ -129,28 +60,282 @@ "complete": "false", "statusVal": "", "statusType": "auto", - "x": 600, - "y": 260, + "x": 610, + "y": 160, "wires": [] }, { - "id": "6d077dfa0987febb", - "type": "comment", - "z": "6312c0588348b2d4", - "name": "↑called from link call node", - "info": "", - "x": 410, - "y": 380, + "id": "06a8cdd201630b4c", + "type": "link call", + "z": "2cdc739225d6a4e8", + "name": "", + "links": [ + "e1b6b1fbd9b33c6d" + ], + "timeout": "30", + "x": 390, + "y": 160, + "wires": [ + [ + "943afa6102c26e24" + ] + ] + }, + { + "id": "b23d2de4047ac09b", + "type": "link call", + "z": "2cdc739225d6a4e8", + "name": "", + "links": [ + "e1b6b1fbd9b33c6d" + ], + "linkType": "dynamic", + "timeout": "30", + "x": 380, + "y": 280, + "wires": [ + [ + "94c83d101ad168b0" + ] + ] + }, + { + "id": "2706a2894820982a", + "type": "inject", + "z": "2cdc739225d6a4e8", + "name": "", + "props": [ + { + "p": "payload" + }, + { + "p": "target", + "v": "subroutine-1", + "vt": "str" + } + ], + "repeat": "", + "crontab": "", + "once": false, + "onceDelay": 0.1, + "topic": "", + "payload": "", + "payloadType": "date", + "x": 200, + "y": 280, + "wires": [ + [ + "b23d2de4047ac09b" + ] + ] + }, + { + "id": "94c83d101ad168b0", + "type": "debug", + "z": "2cdc739225d6a4e8", + "name": "", + "active": true, + "tosidebar": true, + "console": false, + "tostatus": false, + "complete": "false", + "statusVal": "", + "statusType": "auto", + "x": 610, + "y": 280, "wires": [] }, { - "id": "53b9a0adfd8c4217", + "id": "57355f9fbe74f23c", "type": "comment", - "z": "6312c0588348b2d4", - "name": "↑return to link call node", + "z": "2cdc739225d6a4e8", + "name": "↓ set msg.target", "info": "", - "x": 680, - "y": 380, + "x": 200, + "y": 240, + "wires": [] + }, + { + "id": "558bf6ebcececd37", + "type": "inject", + "z": "2cdc739225d6a4e8", + "name": "", + "props": [ + { + "p": "payload" + }, + { + "p": "target", + "v": "subroutine-1", + "vt": "str" + } + ], + "repeat": "", + "crontab": "", + "once": false, + "onceDelay": 0.1, + "topic": "", + "payload": "", + "payloadType": "date", + "x": 200, + "y": 400, + "wires": [ + [ + "9095bcd27e7c3bd7" + ] + ] + }, + { + "id": "4dbf26da9c203018", + "type": "debug", + "z": "2cdc739225d6a4e8", + "name": "", + "active": true, + "tosidebar": true, + "console": false, + "tostatus": false, + "complete": "false", + "statusVal": "", + "statusType": "auto", + "x": 610, + "y": 400, + "wires": [] + }, + { + "id": "2b032ec174258528", + "type": "comment", + "z": "2cdc739225d6a4e8", + "name": "↓ call subroutine-1 from a function", + "info": "", + "x": 450, + "y": 360, + "wires": [] + }, + { + "id": "9095bcd27e7c3bd7", + "type": "function", + "z": "2cdc739225d6a4e8", + "name": "call subroutine-1", + "func": "\nconst m = await node.linkcall('subroutine-1', msg, { timeout: 1000 })\n\nreturn m;", + "outputs": 1, + "timeout": 0, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 400, + "y": 400, + "wires": [ + [ + "4dbf26da9c203018" + ] + ] + }, + { + "id": "4237c6c2ee2e5c03", + "type": "comment", + "z": "2cdc739225d6a4e8", + "name": "↓ call link in (target set by msg.target)", + "info": "", + "x": 470, + "y": 240, + "wires": [] + }, + { + "id": "502424230fe03b8e", + "type": "group", + "z": "2cdc739225d6a4e8", + "name": "subroutine-1", + "style": { + "label": true + }, + "nodes": [ + "e1b6b1fbd9b33c6d", + "547ff90d37be5bfa", + "5dc4c8eee329ed86", + "37c42be69a257586", + "f2a44468d6af6018" + ], + "x": 134, + "y": 499, + "w": 532, + "h": 122 + }, + { + "id": "e1b6b1fbd9b33c6d", + "type": "link in", + "z": "2cdc739225d6a4e8", + "g": "502424230fe03b8e", + "name": "subroutine-1", + "links": [], + "x": 175, + "y": 540, + "wires": [ + [ + "547ff90d37be5bfa" + ] + ] + }, + { + "id": "547ff90d37be5bfa", + "type": "change", + "z": "2cdc739225d6a4e8", + "g": "502424230fe03b8e", + "name": "", + "rules": [ + { + "t": "set", + "p": "payload", + "pt": "msg", + "to": "Hello, from subroutine-1", + "tot": "str" + } + ], + "action": "", + "property": "", + "from": "", + "to": "", + "reg": false, + "x": 400, + "y": 540, + "wires": [ + [ + "5dc4c8eee329ed86" + ] + ] + }, + { + "id": "5dc4c8eee329ed86", + "type": "link out", + "z": "2cdc739225d6a4e8", + "g": "502424230fe03b8e", + "name": "", + "mode": "return", + "links": [], + "x": 625, + "y": 540, + "wires": [] + }, + { + "id": "37c42be69a257586", + "type": "comment", + "z": "2cdc739225d6a4e8", + "g": "502424230fe03b8e", + "name": "↑ link node \"sunbroutine-1\"", + "info": "", + "x": 270, + "y": 580, + "wires": [] + }, + { + "id": "f2a44468d6af6018", + "type": "comment", + "z": "2cdc739225d6a4e8", + "g": "502424230fe03b8e", + "name": "return to caller ↑ ", + "info": "", + "x": 560, + "y": 580, "wires": [] } ] \ No newline at end of file diff --git a/packages/node_modules/@node-red/nodes/locales/en-US/messages.json b/packages/node_modules/@node-red/nodes/locales/en-US/messages.json index cb9badab9..5d6741e68 100644 --- a/packages/node_modules/@node-red/nodes/locales/en-US/messages.json +++ b/packages/node_modules/@node-red/nodes/locales/en-US/messages.json @@ -195,7 +195,9 @@ "dynamicLinkLabel": "Dynamic", "errors": { "missingReturn": "Missing return node information", - "linkUndefined": "link undefined" + "linkUndefined": "link undefined", + "linkCallMultipleTargetsFound": "Multiple 'link in' nodes named '__target__' found", + "linkCallTargetNotFound": "Target 'link in' node '__target__' not found" } }, "tls": { @@ -287,7 +289,10 @@ "inputListener": "Cannot add listener to 'input' event within Function", "non-message-returned": "Function tried to send a message of type __type__", "invalid-js": "Error in JavaScript code", - "missing-module": "Module __module__ missing" + "missing-module": "Module __module__ missing", + "linkcallTargetInvalid" : "Error link call target parameter is not valid, expected a string", + "linkcallMsgInvalid" : "Error link call msg parameter is not valid, expected an object", + "linkcallTargetNotFound": "Error target 'link in' node not found" } }, "template": { diff --git a/packages/node_modules/@node-red/registry/lib/util.js b/packages/node_modules/@node-red/registry/lib/util.js index 5b0601f23..3c6692d12 100644 --- a/packages/node_modules/@node-red/registry/lib/util.js +++ b/packages/node_modules/@node-red/registry/lib/util.js @@ -128,6 +128,7 @@ function createNodeApi(node) { } return i18n._.apply(null,args); } + red.nodes.linkcallTargets = runtime.nodes.linkcallTargets; return red; } diff --git a/packages/node_modules/@node-red/runtime/lib/nodes/index.js b/packages/node_modules/@node-red/runtime/lib/nodes/index.js index 0acfec8c6..439841eb6 100644 --- a/packages/node_modules/@node-red/runtime/lib/nodes/index.js +++ b/packages/node_modules/@node-red/runtime/lib/nodes/index.js @@ -199,6 +199,135 @@ function uninstallModule(module) { } } +const linkcallTargets = (function linkcallTargets () { + let registry = { id: {}, name: {} } + function getIndex (/** @type {[LinkTarget]} */ targets, id) { + for (let index = 0; index < (targets || []).length; index++) { + const element = targets[index] + if (element.id === id) { + return index + } + } + return -1 + } + /** + * Generate a target object from a node + * @param {LinkInNode} node + * @returns {LinkTarget} a link target object + */ + function generateTarget (node) { + const isSubFlow = node._flow.TYPE === 'subflow' + return { + id: node.id, + name: node.name || node.id, + flowId: node._flow.flow.id, + flowName: isSubFlow ? node._flow.subflowDef.name : node._flow.flow.label, + isSubFlow: isSubFlow + } + } + return { + /** + * Get a list of targets registerd to this name + * @param {string} name Name of the target + * @param {boolean} [excludeSubflows] set `true` to exclude + * @returns {[LinkTarget]} Targets registerd to this name. + */ + getTargets (name, excludeSubflows) { + const targets = registry.name[name] || [] + if (excludeSubflows) { + return targets.filter(e => e.isSubFlow !== true) + } + return targets + }, + /** + * Get a single target by registered name. + * To restrict to a single flow, include the `flowId` + * If there is no targets OR more than one target, null is returned + * @param {string} name Name of the node + * @param {string} [flowId] + * @returns {LinkTarget} target + */ + getTarget (name, flowId) { + /** @type {[LinkTarget]} */ + let possibleTargets = this.getTargets(name) + /** @type {LinkTarget} */ + let target + if (possibleTargets.length && flowId) { + possibleTargets = possibleTargets.filter(e => e.flowId === flowId) + } + if (possibleTargets.length === 1) { + target = possibleTargets[0] + } + return target + }, + getTargetNode(srcNode, target, isDynamic) { + const dynamicMode = isDynamic === true; + + // 1st see if the target is a direct node id + let foundNode; + if (this.getTargetById(target)) { + foundNode = flows.get(target) + } + if (target && !foundNode && dynamicMode) { + //next, look in **this flow only** for the node + let cachedTarget = this.getTarget(target, srcNode._flow.flow.id); + if (!cachedTarget) { + //single target node not found in registry! + //get all possible targets from regular flows (exclude subflow instances) + const possibleTargets = this.getTargets(target, true); + if (possibleTargets.length === 1) { + //only 1 link-in found with this name - good, lets use it + cachedTarget = possibleTargets[0]; + } else if (possibleTargets.length > 1) { + //more than 1 link-in has this name, raise an error + throw new Error(`Multiple link-in nodes named '${target}' found`); + } + } + if (cachedTarget) { + foundNode = flows.get(cachedTarget.id) //RED.nodes.getNode(cachedTarget.id); + } + } + if (foundNode && foundNode.constructor?.name === 'LinkInNode') { + return foundNode; + } + throw new Error(`target link-in node '${target || ""}' not found`); + }, + /** + * Get a target by node ID + * @param {string} nodeId ID of the node + * @returns {LinkTarget} target + */ + getTargetById (nodeId) { + return registry.id[nodeId] + }, + register (/** @type {LinkInNode} */ node) { + const target = generateTarget(node) + const tByName = this.getTarget(target.name, target.flowId) + if (!tByName || tByName.id !== target.id) { + registry.name[target.name] = registry.name[target.name] || [] + registry.name[target.name].push(target) + } + registry.id[target.id] = target + return target + }, + remove (node) { + const target = generateTarget(node) + const targs = this.getTargets(target.name) + const idx = getIndex(targs, target.id) + if (idx > -1) { + targs.splice(idx, 1) + } + if (targs.length === 0) { + delete registry.name[target.name] + } + delete registry.id[target.id] + }, + clear () { + registry = { id: {}, name: {} } + } + } +})() + module.exports = { // Lifecycle init: init, @@ -219,6 +348,8 @@ module.exports = { enableNode: enableNode, disableNode: disableNode, + linkcallTargets: linkcallTargets, + // Node type registry registerType: registerType, registerSubflow: registerSubflow, diff --git a/test/nodes/core/function/10-function_spec.js b/test/nodes/core/function/10-function_spec.js index 6a04547f4..03468ec6b 100644 --- a/test/nodes/core/function/10-function_spec.js +++ b/test/nodes/core/function/10-function_spec.js @@ -14,11 +14,13 @@ * limitations under the License. **/ -var should = require("should"); -var functionNode = require("nr-test-utils").require("@node-red/nodes/core/function/10-function.js"); -var Context = require("nr-test-utils").require("@node-red/runtime/lib/nodes/context"); -var helper = require("node-red-node-test-helper"); -var RED = require("nr-test-utils").require("node-red/lib/red"); +const should = require("should"); +const sinon = require("sinon"); +const functionNode = require("nr-test-utils").require("@node-red/nodes/core/function/10-function.js"); +const linkNode = require("nr-test-utils").require("@node-red/nodes/core/common/60-link.js"); +const Context = require("nr-test-utils").require("@node-red/runtime/lib/nodes/context"); +const helper = require("node-red-node-test-helper"); +const RED = require("nr-test-utils").require("node-red/lib/red"); describe('function node', function() { before(function(done) { @@ -1835,4 +1837,492 @@ describe('function node', function() { }); }); + + describe('link call from function', function () { + afterEach(function () { + delete RED.settings.functionExternalModules; + }) + it('should call subroutine on same tab by name and get response', async function () { + const flow = [ + // ↓↓ subroutine ↓↓ + { id: "li1", type: "link in", name: "subroutine", wires: [["sbn"]] }, + { id: "sbn", type: "function", wires: [["lo1"]], func: "msg._payload=msg.payload; msg.payload='set-in-function'; return msg;" }, + { id: "lo1", type: "link out", mode: "return" }, + // ↓↓ main flow ↓↓ + { id: "f1", type: "function", wires: [["h1"]], func: "const m = await node.linkcall('subroutine', msg, { timeout: 1000 }); return m;" }, + { id: "c1", type: "catch", scope: ["f1"], uncaught: true, wires: [["h1"]] }, + { id: "h1", type: "helper" } + ] + + await helper.load([linkNode, functionNode], flow) + const f1 = helper.getNode("f1"); + const h1 = helper.getNode("h1"); + const c1 = helper.getNode("c1"); + const c1SpyReceived = sinon.spy(c1, "receive") + + await new Promise((resolve, reject) => { + h1.on("input", function (msg) { + try { + c1SpyReceived.called.should.be.false() // should not have been caught as error + msg.should.have.property("payload", "set-in-function") + msg.should.have.property("_payload", "original") + msg.should.not.have.property("error") + resolve() + } catch (err) { + reject(err) + } + }) + f1.receive({ payload: "original", topic: "test" }) // trigger function node which will call the subroutine + }) + }) + it('should call subroutine on different tab by name and get response', async function () { + const flow = [ + // ↓↓ flow tabs ↓↓ + { id: "tab-flow-main", type: "tab", label: "Main Flow" }, + { id: "tab-flow-2", type: "tab", label: "Flow 2" }, + // ↓↓ subroutine ↓↓ + { id: "li1", type: "link in", z: "tab-flow-2", name: "subroutine-on-different-tab", wires: [["sbn"]] }, + { id: "sbn", type: "function", z: "tab-flow-2", wires: [["lo1"]], func: "msg._payload=msg.payload; msg.payload='set-in-function'; return msg;" }, + { id: "lo1", type: "link out", z: "tab-flow-2", mode: "return" }, + // ↓↓ main flow ↓↓ + { id: "f1", type: "function", z: "tab-flow-main", wires: [["h1"]], func: "const m = await node.linkcall('subroutine-on-different-tab', msg, { timeout: 1000 }); return m;" }, + { id: "c1", type: "catch", z: "tab-flow-main", scope: ["f1"], uncaught: true, wires: [["h1"]] }, + { id: "h1", type: "helper", z: "tab-flow-main" } + ] + + await helper.load([linkNode, functionNode], flow) + const f1 = helper.getNode("f1"); + const h1 = helper.getNode("h1"); + const c1 = helper.getNode("c1"); + const c1SpyReceived = sinon.spy(c1, "receive") + const li1 = helper.getNode("li1"); + const li1SpyReceived = sinon.spy(li1, "receive") + + await new Promise((resolve, reject) => { + h1.on("input", function (msg) { + try { + li1SpyReceived.called.should.be.true() // subroutine should have been called + c1SpyReceived.called.should.be.false() // should not have been caught as error + msg.should.have.property("payload", "set-in-function") + msg.should.have.property("_payload", "original") + msg.should.not.have.property("error") + resolve() + } catch (err) { + reject(err) + } + }) + f1.receive({ payload: "original", topic: "test" }) // trigger function node which will call the subroutine + }) + }) + it('should call subroutine on same tab by node id and get response', async function () { + const flow = [ + // ↓↓ subroutine ↓↓ + { id: "li1", type: "link in", name: "subroutine", wires: [["sbn"]] }, + { id: "sbn", type: "function", wires: [["lo1"]], func: "msg._payload=msg.payload; msg.payload='set-in-function'; return msg;" }, + { id: "lo1", type: "link out", mode: "return" }, + // ↓↓ main flow ↓↓ + { id: "f1", type: "function", wires: [["h1"]], func: "const m = await node.linkcall('li1', msg, { timeout: 1000 }); return m;" }, + { id: "c1", type: "catch", scope: ["f1"], uncaught: true, wires: [["h1"]] }, + { id: "h1", type: "helper" } + ] + + await helper.load([linkNode, functionNode], flow) + const f1 = helper.getNode("f1"); + const h1 = helper.getNode("h1"); + const c1 = helper.getNode("c1"); + const c1SpyReceived = sinon.spy(c1, "receive") + + await new Promise((resolve, reject) => { + h1.on("input", function (msg) { + try { + c1SpyReceived.called.should.be.false() // should not have been caught as error + msg.should.have.property("payload", "set-in-function") + msg.should.have.property("_payload", "original") + msg.should.not.have.property("error") + resolve() + } catch (err) { + reject(err) + } + }) + f1.receive({ payload: "original", topic: "test" }) // trigger function node which will call the subroutine + }) + }) + it('should call subroutine on different tab by node id and get response', async function () { + const flow = [ + // ↓↓ flow tabs ↓↓ + { id: "tab-flow-main", type: "tab", label: "Main Flow" }, + { id: "tab-flow-2", type: "tab", label: "Flow 2" }, + // ↓↓ subroutine on flow 2 ↓↓ + { id: "li2", type: "link in", z: "tab-flow-2", name: "subroutine", wires: [["sbn2"]] }, + { id: "sbn2", type: "function", z: "tab-flow-2", wires: [["lo2"]], func: "msg._payload=msg.payload; msg.payload='set-in-function'; return msg;" }, + { id: "lo2", type: "link out", z: "tab-flow-2", mode: "return" }, + // ↓↓ main flow ↓↓ + { id: "f1", type: "function", z: "tab-flow-main", wires: [["h1"]], func: "const m = await node.linkcall('li2', msg, { timeout: 500 }); return m;" }, + { id: "c1", type: "catch", z: "tab-flow-main", scope: ["f1"], uncaught: true, wires: [["h1"]] }, + { id: "h1", type: "helper", z: "tab-flow-main" } + ] + + await helper.load([linkNode, functionNode], flow) + const f1 = helper.getNode("f1"); + const h1 = helper.getNode("h1"); + const c1 = helper.getNode("c1"); + const li2 = helper.getNode("li2"); + const c1SpyReceived = sinon.spy(c1, "receive") + const li2SpyReceived = sinon.spy(li2, "receive") + + await new Promise((resolve, reject) => { + h1.on("input", function (msg) { + try { + li2SpyReceived.called.should.be.true() // subroutine should have been called + c1SpyReceived.called.should.be.false() // should not have been caught as error + msg.should.have.property("payload", "set-in-function") + msg.should.have.property("_payload", "original") + msg.should.not.have.property("error") + resolve() + } catch (err) { + reject(err) + } + }) + f1.receive({ payload: "original", topic: "test" }) // trigger function node which will call the subroutine + }) + }) + it('should clone the message by default', async function () { + const flow = [ + // ↓↓ subroutine ↓↓ + { id: "li1", type: "link in", name: "subroutine", wires: [["sbn"]] }, + { id: "sbn", type: "function", wires: [["lo1"]], func: "msg.payload.prop='modified'; return msg;" }, + { id: "lo1", type: "link out", mode: "return" }, + // ↓↓ main flow ↓↓ + { id: "f1", + type: "function", + wires: [["h1"]], + func: ` + const payloadPropBefore = msg.payload.prop; + const newMsg = await node.linkcall('li1', msg); + const payloadPropAfter = msg.payload.prop; + const newPayloadProp = newMsg.payload.prop; + msg.testData = {payloadPropBefore, payloadPropAfter, newPayloadProp}; // attach data to message to make it easier to assert in test + return msg; + ` + }, + { id: "c1", type: "catch", scope: ["f1"], uncaught: true, wires: [["h1"]] }, + { id: "h1", type: "helper" } + ] + + await helper.load([linkNode, functionNode], flow) + const f1 = helper.getNode("f1"); + const h1 = helper.getNode("h1"); + + await new Promise((resolve, reject) => { + h1.on("input", function (msg) { + try { + msg.should.have.property('payload').and.have.property('prop', 'original') + should(msg.testData).have.property('payloadPropBefore', 'original') + should(msg.testData).have.property('payloadPropAfter', 'original') + should(msg.testData).have.property('newPayloadProp', 'modified') + resolve() + } catch (err) { + reject(err) + } + }) + f1.receive({ payload: { prop: "original" }, topic: "test" }) // trigger function node which will call the subroutine + }) + }) + it('should not clone the message when clone option=false', async function () { + const flow = [ + // ↓↓ subroutine ↓↓ + { id: "li1", type: "link in", name: "subroutine", wires: [["sbn"]] }, + { id: "sbn", type: "function", wires: [["lo1"]], func: "msg.payload.prop='modified'; return msg;" }, + { id: "lo1", type: "link out", mode: "return" }, + // ↓↓ main flow ↓↓ + { id: "f1", + type: "function", + wires: [["h1"]], + func: ` + const payloadPropBefore = msg.payload.prop; + const newMsg = await node.linkcall('li1', msg, { clone: false }); + const payloadPropAfter = msg.payload.prop; + const newPayloadProp = newMsg.payload.prop; + msg.testData = {payloadPropBefore, payloadPropAfter, newPayloadProp}; // attach data to message to make it easier to assert in test + return msg; + ` + }, + { id: "c1", type: "catch", scope: ["f1"], uncaught: true, wires: [["h1"]] }, + { id: "h1", type: "helper" } + ] + + await helper.load([linkNode, functionNode], flow) + const f1 = helper.getNode("f1"); + const h1 = helper.getNode("h1"); + + await new Promise((resolve, reject) => { + h1.on("input", function (msg) { + try { + msg.should.have.property('payload').and.have.property('prop', 'modified') // original message should have been mutated by subroutine + should(msg.testData).have.property('payloadPropBefore', 'original') + should(msg.testData).have.property('payloadPropAfter', 'modified') + should(msg.testData).have.property('newPayloadProp', 'modified') + resolve() + } catch (err) { + reject(err) + } + }) + f1.receive({ payload: { prop: "original" }, topic: "test" }) // trigger function node which will call the subroutine + }) + }) + it('should call subroutine on same tab even when there are same named targets on other tabs', async function () { + const flow = [ + // ↓↓ flow tabs ↓↓ + { id: "tab-flow-main", type: "tab", label: "Main Flow" }, + { id: "tab-flow-2", type: "tab", label: "Flow 2" }, + // ↓↓ subroutine on main flow ↓↓ + { id: "li1", type: "link in", z: "tab-flow-main", name: "subroutine", wires: [["sbn"]] }, + { id: "sbn", type: "function", z: "tab-flow-main", wires: [["lo1"]], func: "msg._payload=msg.payload; msg.payload='set-in-function'; return msg;" }, + { id: "lo1", type: "link out", z: "tab-flow-main", mode: "return" }, + // ↓↓ subroutine on flow 2 ↓↓ + { id: "li2", type: "link in", z: "tab-flow-2", name: "subroutine", wires: [["sbn2"]] }, + { id: "sbn2", type: "function", z: "tab-flow-2", wires: [["lo2"]], func: "msg._payload=msg.payload; msg.payload='set-in-function'; return msg;" }, + { id: "lo2", type: "link out", z: "tab-flow-2", mode: "return" }, + // ↓↓ main flow ↓↓ + { id: "f1", type: "function", z: "tab-flow-main", wires: [["h1"]], func: "const m = await node.linkcall('subroutine', msg, { timeout: 500 }); return m;" }, + { id: "c1", type: "catch", z: "tab-flow-main", scope: ["f1"], uncaught: true, wires: [["h1"]] }, + { id: "h1", type: "helper", z: "tab-flow-main" } + ] + + await helper.load([linkNode, functionNode], flow) + const f1 = helper.getNode("f1"); + const h1 = helper.getNode("h1"); + const c1 = helper.getNode("c1"); + const li1 = helper.getNode("li1"); + const li2 = helper.getNode("li2"); + const c1SpyReceived = sinon.spy(c1, "receive") + const li1SpyReceived = sinon.spy(li1, "receive") + const li2SpyReceived = sinon.spy(li2, "receive") + + await new Promise((resolve, reject) => { + h1.on("input", function (msg) { + try { + li1SpyReceived.called.should.be.true() // subroutine on same tab should have been called + li2SpyReceived.called.should.be.false() // subroutine on different tab should not have been called + c1SpyReceived.called.should.be.false() // should not have been caught as error + msg.should.have.property("payload", "set-in-function") + msg.should.have.property("_payload", "original") + msg.should.not.have.property("error") + resolve() + } catch (err) { + reject(err) + } + }) + f1.receive({ payload: "original", topic: "test" }) // trigger function node which will call the subroutine + }) + }) + it('should call nested subroutines', async function () { + const flow = [ + // ↓↓ flow tabs ↓↓ + { id: "tab-flow-main", type: "tab", label: "Main Flow" }, + { id: "tab-flow-2", type: "tab", label: "Flow 2" }, + // ↓↓ subroutine on main flow ↓↓ + { id: "li1", type: "link in", z: "tab-flow-main", name: "subroutine", wires: [["sbn"]] }, + { id: "sbn", type: "function", z: "tab-flow-main", wires: [["lo1"]], func: "const m = await node.linkcall('subroutine2', msg, { timeout: 500 }); return m;" }, + { id: "lo1", type: "link out", z: "tab-flow-main", mode: "return" }, + // ↑↑ subroutine end ↑↑ + // ↓↓ subroutine on flow 2 ↓↓ + { id: "li2", type: "link in", z: "tab-flow-2", name: "subroutine2", wires: [["sbn2"]] }, + { id: "sbn2", type: "function", z: "tab-flow-2", wires: [["lo2"]], func: "msg._payload=msg.payload; msg.payload='set-in-subroutine-2'; return msg;" }, + { id: "lo2", type: "link out", z: "tab-flow-2", mode: "return" }, + // ↑↑ subroutine end ↑↑ + { id: "f1", type: "function", z: "tab-flow-main", wires: [["h1"]], func: "const m = await node.linkcall('subroutine', msg, { timeout: 500 }); return m;" }, + { id: "c1", type: "catch", z: "tab-flow-main", scope: ["f1"], uncaught: true, wires: [["h1"]] }, + { id: "h1", type: "helper", z: "tab-flow-main" } + ] + + await helper.load([linkNode, functionNode], flow) + const f1 = helper.getNode("f1"); + const h1 = helper.getNode("h1"); + const c1 = helper.getNode("c1"); + const c1SpyReceived = sinon.spy(c1, "receive") + const li1 = helper.getNode("li1"); + const li1SpyReceived = sinon.spy(li1, "receive") + const li2 = helper.getNode("li2"); + const li2SpyReceived = sinon.spy(li2, "receive") + + await new Promise((resolve, reject) => { + h1.on("input", function (msg) { + try { + li1SpyReceived.called.should.be.true() // subroutine on same tab should have been called + li2SpyReceived.called.should.be.true() // nested subroutine should have been called + c1SpyReceived.called.should.be.false() // should not have been caught as error + msg.should.have.property("payload", "set-in-subroutine-2") + msg.should.have.property("_payload", "original") + msg.should.not.have.property("error") + resolve() + } catch (err) { + reject(err) + } + }) + f1.receive({ payload: "original", topic: "test" }) // trigger function node which will call the subroutine + }) + }) + it('should timeout waiting for link return', async function () { + this.timeout(1000); + const flow = [ + // ↓↓ flow tabs ↓↓ + { id: "tab-flow-main", type: "tab", label: "Main Flow" }, + // ↓↓ subroutine ↓↓ + { id: "li1", type: "link in", z: "tab-flow-main", name: "subroutine", wires: [["sbn"]] }, + { id: "sbn", type: "function", z: "tab-flow-main", wires: [["lo1"]], func: "msg._payload=msg.payload; msg.payload='set-in-function'; return msg;" }, + { id: "lo1", type: "link out", z: "tab-flow-main", mode: "" }, // not return mode, cause link-call timeout + // ↓↓ main flow ↓↓ + { id: "f1", type: "function", z: "tab-flow-main", wires: [["h1"]], func: "const m = await node.linkcall('subroutine', msg, { timeout: 500 }); return m;" }, + { id: "c1", type: "catch", z: "tab-flow-main", scope: ["f1"], uncaught: true, wires: [["h1"]] }, + { id: "h1", type: "helper", z: "tab-flow-main" } + ] + + await helper.load([linkNode, functionNode], flow) + const f1 = helper.getNode("f1") + const h1 = helper.getNode("h1") + const c1 = helper.getNode("c1") + const li1 = helper.getNode("li1") + const c1SpyReceived = sinon.spy(c1, "receive") + const li1SpyReceived = sinon.spy(li1, "receive") + + await new Promise((resolve, reject) => { + h1.on("input", function (msg) { + try { + li1SpyReceived.called.should.be.true() // subroutine should have been called + c1SpyReceived.called.should.be.true() // should have been caught as error + msg.error.should.have.property("message").and.match(/timeout/) + msg.error.should.have.property("source") + msg.error.source.should.have.property("id", "f1") + resolve() + } catch (err) { + reject(err) + } + }) + f1.receive({ payload: "original", topic: "test" }) + }) + }) + it('should raise error for non-existent target subroutine', async function () { + this.timeout(1000); + const flow = [ + // ↓↓ flow tabs ↓↓ + { id: "tab-flow-main", type: "tab", label: "Main Flow" }, + // ↓↓ subroutine ↓↓ + { id: "li1", type: "link in", z: "tab-flow-main", name: "subroutine", wires: [["sbn"]] }, + { id: "sbn", type: "function", z: "tab-flow-main", wires: [["lo1"]], func: "msg._payload=msg.payload; msg.payload='set-in-function'; return msg;" }, + { id: "lo1", type: "link out", z: "tab-flow-main", mode: "" }, // not return mode, cause link-call timeout + // ↓↓ main flow ↓↓ + { id: "f1", type: "function", z: "tab-flow-main", wires: [["h1"]], func: "const m = await node.linkcall('non-existent-subroutine', msg, { timeout: 500 }); return m;" }, + { id: "c1", type: "catch", z: "tab-flow-main", scope: ["f1"], uncaught: true, wires: [["h1"]] }, + { id: "h1", type: "helper", z: "tab-flow-main" } + ] + + await helper.load([linkNode, functionNode], flow) + const f1 = helper.getNode("f1") + const h1 = helper.getNode("h1") + const c1 = helper.getNode("c1") + const li1 = helper.getNode("li1") + const c1SpyReceived = sinon.spy(c1, "receive") + const li1SpyReceived = sinon.spy(li1, "receive") + + await new Promise((resolve, reject) => { + h1.on("input", function (msg) { + try { + li1SpyReceived.called.should.be.false() // subroutine should not have been called + c1SpyReceived.called.should.be.true() // should have been caught as error + msg.error.should.have.property("message").and.match(/target link-in node \'non-existent-subroutine\' not found/i) + msg.error.should.have.property("source") + msg.error.source.should.have.property("id", "f1") + resolve() + } catch (err) { + reject(err) + } + }) + f1.receive({ payload: "original", topic: "test" }) + }) + }) + it('should raise error due to multiple targets on same tab', async function () { + const flow = [ + // ↓↓ flow tabs ↓↓ + { id: "tab-flow-main", type: "tab", label: "Main Flow" }, + // ↓↓ subroutine ↓↓ + { id: "li1", type: "link in", z: "tab-flow-main", name: "subroutine", wires: [["sbn"]] }, + { id: "li2", type: "link in", z: "tab-flow-main", name: "subroutine", wires: [["sbn"]] }, + { id: "sbn", type: "function", z: "tab-flow-main", wires: [["lo1"]], func: "msg._payload=msg.payload; msg.payload='set-in-function'; return msg;" }, + { id: "lo1", type: "link out", z: "tab-flow-main", mode: "return" }, + // ↓↓ main flow ↓↓ + { id: "f1", type: "function", z: "tab-flow-main", wires: [["h1"]], func: "const m = await node.linkcall('subroutine', msg, { timeout: 500 }); return m;" }, + { id: "c1", type: "catch", z: "tab-flow-main", scope: ["f1"], uncaught: true, wires: [["h1"]] }, + { id: "h1", type: "helper", z: "tab-flow-main" } + ] + + await helper.load([linkNode, functionNode], flow) + const f1 = helper.getNode("f1") + const h1 = helper.getNode("h1") + const c1 = helper.getNode("c1") + const li1 = helper.getNode("li1") + const c1SpyReceived = sinon.spy(c1, "receive") + const li1SpyReceived = sinon.spy(li1, "receive") + + await new Promise((resolve, reject) => { + h1.on("input", function (msg) { + try { + li1SpyReceived.called.should.be.false() // subroutine should not have been called + c1SpyReceived.called.should.be.true() // should have been caught as error + msg.error.should.have.property("message").and.match(/Multiple link-in nodes named 'subroutine' found/i) + msg.error.should.have.property("source") + msg.error.source.should.have.property("id", "f1") + resolve() + } catch (err) { + reject(err) + } + }) + f1.receive({ payload: "original", topic: "test" }) + }) + }) + it('should raise error due to multiple targets on different tabs', async function () { + const flow = [ + // ↓↓ flow tabs ↓↓ + { id: "tab-flow-main", type: "tab", label: "Main Flow" }, + { id: "tab-flow-2", type: "tab", label: "Flow 2" }, + { id: "tab-flow-3", type: "tab", label: "Flow 3" }, + // ↓↓ subroutine on flow 2 ↓↓ + { id: "li1", type: "link in", z: "tab-flow-2", name: "subroutine", wires: [["sbn"]] }, + { id: "sbn", type: "function", z: "tab-flow-2", wires: [["lo1"]], func: "msg._payload=msg.payload; msg.payload='set-in-function'; return msg;" }, + { id: "lo1", type: "link out", z: "tab-flow-2", mode: "return" }, + // ↓↓ subroutine on flow 3 ↓↓ + { id: "li2", type: "link in", z: "tab-flow-3", name: "subroutine", wires: [["sbn2"]] }, + { id: "sbn2", type: "function", z: "tab-flow-3", wires: [["lo2"]], func: "msg._payload=msg.payload; msg.payload='set-in-function'; return msg;" }, + { id: "lo2", type: "link out", z: "tab-flow-3", mode: "return" }, + // ↓↓ main flow ↓↓ + { id: "f1", type: "function", z: "tab-flow-main", wires: [["h1"]], func: "const m = await node.linkcall('subroutine', msg, { timeout: 500 }); return m;" }, + { id: "c1", type: "catch", z: "tab-flow-main", scope: ["f1"], uncaught: true, wires: [["h1"]] }, + { id: "h1", type: "helper", z: "tab-flow-main" } + ] + + await helper.load([linkNode, functionNode], flow) + const f1 = helper.getNode("f1") + const h1 = helper.getNode("h1") + const c1 = helper.getNode("c1") + const li1 = helper.getNode("li1") + const c1SpyReceived = sinon.spy(c1, "receive") + const li1SpyReceived = sinon.spy(li1, "receive") + + await new Promise((resolve, reject) => { + h1.on("input", function (msg) { + try { + li1SpyReceived.called.should.be.false() // subroutine should not have been called + c1SpyReceived.called.should.be.true() // should have been caught as error + msg.error.should.have.property("message").and.match(/Multiple link-in nodes named 'subroutine' found/i) + msg.error.should.have.property("source") + msg.error.source.should.have.property("id", "f1") + resolve() + } catch (err) { + reject(err) + } + }) + f1.receive({ payload: "original", topic: "test" }) + }) + }) + }) });