pull/5494/merge
Stephen McLaughlin 2026-03-24 10:15:09 -04:00 committed by GitHub
commit 7164a2eba8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 1114 additions and 247 deletions

View File

@ -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 ~ <nodes> ~ 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<Object>} - 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<object>;
// #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 = [];

View File

@ -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<Object>} - 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<object>;
// #endregion:linkcall
/** the id of this node */
public static readonly id:string;
/** the name of this node */

View File

@ -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<Object>} - resolves with the returned message
* @memberof @node-red/util_util
*/
function linkcall(target: string, msg: object, options?: object): Promise<object>;
}
}

View File

@ -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);
}

View File

@ -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

View File

@ -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: {

View File

@ -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": []
}
]

View File

@ -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": {

View File

@ -128,6 +128,7 @@ function createNodeApi(node) {
}
return i18n._.apply(null,args);
}
red.nodes.linkcallTargets = runtime.nodes.linkcallTargets;
return red;
}

View File

@ -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,

View File

@ -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" })
})
})
})
});