From bdd736315affa3602ee53d95df53cebea6b06f26 Mon Sep 17 00:00:00 2001 From: Nick O'Leary Date: Thu, 30 Jul 2020 17:52:11 +0100 Subject: [PATCH] Add RED.hooks engine --- Gruntfile.js | 1 + .../@node-red/registry/lib/util.js | 1 + .../@node-red/runtime/lib/flows/index.js | 2 - .../@node-red/runtime/lib/hooks.js | 165 +++++++++++++ .../@node-red/runtime/lib/index.js | 3 + packages/node_modules/node-red/lib/red.js | 8 + test/unit/@node-red/runtime/lib/hooks_spec.js | 221 ++++++++++++++++++ 7 files changed, 399 insertions(+), 2 deletions(-) create mode 100644 packages/node_modules/@node-red/runtime/lib/hooks.js create mode 100644 test/unit/@node-red/runtime/lib/hooks_spec.js diff --git a/Gruntfile.js b/Gruntfile.js index 1eaf3f6a9..ff711e654 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -457,6 +457,7 @@ module.exports = function(grunt) { 'packages/node_modules/@node-red/runtime/lib/index.js', 'packages/node_modules/@node-red/runtime/lib/api/*.js', 'packages/node_modules/@node-red/runtime/lib/events.js', + 'packages/node_modules/@node-red/runtime/lib/hooks.js', 'packages/node_modules/@node-red/util/**/*.js', 'packages/node_modules/@node-red/editor-api/lib/index.js', 'packages/node_modules/@node-red/editor-api/lib/auth/index.js' diff --git a/packages/node_modules/@node-red/registry/lib/util.js b/packages/node_modules/@node-red/registry/lib/util.js index 4e68caf51..5a6d3da38 100644 --- a/packages/node_modules/@node-red/registry/lib/util.js +++ b/packages/node_modules/@node-red/registry/lib/util.js @@ -57,6 +57,7 @@ function createNodeApi(node) { log: {}, settings: {}, events: runtime.events, + hooks: runtime.hooks, util: runtime.util, version: runtime.version, require: requireModule, diff --git a/packages/node_modules/@node-red/runtime/lib/flows/index.js b/packages/node_modules/@node-red/runtime/lib/flows/index.js index 157f384c8..ec46d8374 100644 --- a/packages/node_modules/@node-red/runtime/lib/flows/index.js +++ b/packages/node_modules/@node-red/runtime/lib/flows/index.js @@ -24,7 +24,6 @@ var deprecated = typeRegistry.deprecated; var context = require("../nodes/context") var credentials = require("../nodes/credentials"); -var router = require("./router"); var flowUtil = require("./util"); var log; var events = require("../events"); @@ -70,7 +69,6 @@ function init(runtime) { } Flow.init(runtime); flowUtil.init(runtime); - router.init(runtime); } function loadFlows() { diff --git a/packages/node_modules/@node-red/runtime/lib/hooks.js b/packages/node_modules/@node-red/runtime/lib/hooks.js new file mode 100644 index 000000000..b8333302f --- /dev/null +++ b/packages/node_modules/@node-red/runtime/lib/hooks.js @@ -0,0 +1,165 @@ +const Log = require("@node-red/util").log; + +// Flags for what hooks have handlers registered +let states = { } + +// Hooks by id +let hooks = { } + +// Hooks by label +let labelledHooks = { } + +/** + * Runtime hooks engine + * @mixin @node-red/runtime_hooks + */ + +/** + * Register a handler to a named hook + * @memberof @node-red/runtime_hooks + * @param {String} hookId - the name of the hook to attach to + * @param {Function} callback - the callback function for the hook + */ +function add(hookId, callback) { + let [id, label] = hookId.split("."); + if (label) { + if (labelledHooks[label] && labelledHooks[label][id]) { + throw new Error("Hook "+hookId+" already registered") + } + labelledHooks[label] = labelledHooks[label]||{}; + labelledHooks[label][id] = callback; + } + // Get location of calling code + const stack = new Error().stack; + const callModule = stack.split("\n")[2].split("(")[1].slice(0,-1); + Log.debug(`Adding hook '${hookId}' from ${callModule}`); + + hooks[id] = hooks[id] || []; + hooks[id].push({cb:callback,location:callModule}); + states[id] = true; +} + +/** + * Remove a handled from a named hook + * @memberof @node-red/runtime_hooks + * @param {String} hookId - the name of the hook event to remove - must be `name.label` + */ +function remove(hookId) { + let [id,label] = hookId.split("."); + if ( !label) { + throw new Error("Cannot remove hook without label: ",hookId) + } + Log.debug(`Removing hook '${hookId}'`); + if (labelledHooks[label]) { + if (id === "*") { + // Remove all hooks for this label + let hookList = Object.keys(labelledHooks[label]); + for (let i=0;i hook.cb === callback); + if (i !== -1) { + hooks[id].splice(i,1); + if (hooks[id].length === 0) { + delete hooks[id]; + delete states[id]; + } + } +} + + +function trigger(hookId, payload, done) { + const hookStack = hooks[hookId]; + if (!hookStack || hookStack.length === 0) { + done(); + return; + } + let i = 0; + + function callNextHook(err) { + if (i === hookStack.length || err) { + done(err); + return; + } + const hook = hookStack[i++]; + const callback = hook.cb; + if (callback.length === 1) { + try { + let result = callback(payload); + if (result === false) { + // Halting the flow + done(false); + return + } + if (result && typeof result.then === 'function') { + result.then(handleResolve, callNextHook) + return; + } + callNextHook(); + } catch(err) { + done(err); + return; + } + } else { + try { + callback(payload,handleResolve) + } catch(err) { + done(err); + return; + } + } + } + callNextHook(); + + function handleResolve(result) { + if (result === undefined) { + callNextHook(); + } else { + done(result); + } + } +} + +// add("preSend", function(sendEvents) { +// console.log("preSend",JSON.stringify(sendEvents)); +// }) +// add("preRoute", function(sendEvent) { +// console.log("preRoute",JSON.stringify(sendEvent.msg)); +// }) +// add("onSend", function(sendEvent) { +// console.log("onSend",JSON.stringify(sendEvent.msg)); +// }) +// add("postSend", function(sendEvent) { +// console.log("postSend",JSON.stringify(sendEvent.msg)); +// }) +// add("onReceive", function(recEvent) { +// console.log("onReceive",recEvent.destination.id,JSON.stringify(recEvent.msg)) +// }) +// add("postReceive", function(recEvent) { +// console.log("postReceive",recEvent.destination.id,JSON.stringify(recEvent.msg)) +// }) + +function clear() { + hooks = {} + labelledHooks = {} + states = {} +} +module.exports = { + get states() { return states }, + clear, + add, + remove, + trigger +} \ No newline at end of file diff --git a/packages/node_modules/@node-red/runtime/lib/index.js b/packages/node_modules/@node-red/runtime/lib/index.js index 21e775393..f9a772ea3 100644 --- a/packages/node_modules/@node-red/runtime/lib/index.js +++ b/packages/node_modules/@node-red/runtime/lib/index.js @@ -23,6 +23,7 @@ var flows = require("./flows"); var storage = require("./storage"); var library = require("./library"); var events = require("./events"); +var hooks = require("./hooks"); var settings = require("./settings"); var exec = require("./exec"); @@ -273,6 +274,7 @@ var runtime = { settings: settings, storage: storage, events: events, + hooks: hooks, nodes: redNodes, flows: flows, library: library, @@ -357,6 +359,7 @@ module.exports = { storage: storage, events: events, + hooks: hooks, util: require("@node-red/util").util, get httpNode() { return nodeApp }, get httpAdmin() { return adminApp }, diff --git a/packages/node_modules/node-red/lib/red.js b/packages/node_modules/node-red/lib/red.js index 9a4d77862..684ff6d8a 100644 --- a/packages/node_modules/node-red/lib/red.js +++ b/packages/node_modules/node-red/lib/red.js @@ -145,6 +145,14 @@ module.exports = { */ events: runtime.events, + /** + * Runtime hooks engine + * @see @node-red/runtime_hooks + * @memberof node-red + */ + hooks: runtime.hooks, + + /** * This provides access to the internal settings module of the * runtime. diff --git a/test/unit/@node-red/runtime/lib/hooks_spec.js b/test/unit/@node-red/runtime/lib/hooks_spec.js new file mode 100644 index 000000000..79c8c6bce --- /dev/null +++ b/test/unit/@node-red/runtime/lib/hooks_spec.js @@ -0,0 +1,221 @@ +const should = require("should"); +const NR_TEST_UTILS = require("nr-test-utils"); + +const hooks = NR_TEST_UTILS.require("@node-red/runtime/lib/hooks"); + +describe("runtime/hooks", function() { + afterEach(function() { + hooks.clear(); + }) + it("allows a hook to be registered", function(done) { + let calledWith = null; + should.not.exist(hooks.states.foo); + hooks.add("foo", function(payload) { calledWith = payload } ) + hooks.states.foo.should.be.true(); + let data = { a: 1 }; + hooks.trigger("foo",data,err => { + calledWith.should.equal(data); + done(err); + }) + }) + + it("calls hooks in the order they were registered", function(done) { + hooks.add("foo", function(payload) { payload.order.push("A") } ) + hooks.add("foo", function(payload) { payload.order.push("B") } ) + let data = { order:[] }; + hooks.trigger("foo",data,err => { + data.order.should.eql(["A","B"]) + done(err); + }) + }) + + it("does not allow multiple hooks with same id.label", function() { + hooks.add("foo.one", function(payload) { payload.order.push("A") } ); + (function() { + hooks.add("foo.one", function(payload) { payload.order.push("B") } ) + }).should.throw(); + }) + + it("removes labelled hook", function(done) { + hooks.add("foo.A", function(payload) { payload.order.push("A") } ) + hooks.add("foo.B", function(payload) { payload.order.push("B") } ) + + hooks.remove("foo.A"); + hooks.states.foo.should.be.true(); + + let data = { order:[] }; + hooks.trigger("foo",data,err => { + try { + data.order.should.eql(["B"]) + + hooks.remove("foo.B"); + should.not.exist(hooks.states.foo); + + done(err); + } catch(err2) { + done(err2); + } + }) + }) + + it("cannot remove unlabelled hook", function() { + hooks.add("foo", function(payload) { payload.order.push("A") } ); + (function() { + hooks.remove("foo") + }).should.throw(); + }) + it("removes all hooks with same label", function(done) { + hooks.add("foo.A", function(payload) { payload.order.push("A") } ) + hooks.add("foo.B", function(payload) { payload.order.push("B") } ) + hooks.add("bar.A", function(payload) { payload.order.push("C") } ) + hooks.add("bar.B", function(payload) { payload.order.push("D") } ) + + let data = { order:[] }; + hooks.trigger("foo",data,err => { + data.order.should.eql(["A","B"]) + hooks.trigger("bar", data, err => { + data.order.should.eql(["A","B","C","D"]) + + data.order = []; + + hooks.remove("*.A"); + + hooks.trigger("foo",data,err => { + data.order.should.eql(["B"]) + hooks.trigger("bar", data, err => { + data.order.should.eql(["B","D"]) + }) + done(err); + }) + }) + }) + }) + + + it("halts execution on return false", function(done) { + hooks.add("foo.A", function(payload) { payload.order.push("A"); return false } ) + hooks.add("foo.B", function(payload) { payload.order.push("B") } ) + + let data = { order:[] }; + hooks.trigger("foo",data,err => { + data.order.should.eql(["A"]) + err.should.be.false(); + done(); + }) + }) + it("halts execution on thrown error", function(done) { + hooks.add("foo.A", function(payload) { payload.order.push("A"); throw new Error("error") } ) + hooks.add("foo.B", function(payload) { payload.order.push("B") } ) + + let data = { order:[] }; + hooks.trigger("foo",data,err => { + data.order.should.eql(["A"]) + should.exist(err); + err.should.not.be.false() + done(); + }) + }) + + it("handler can use callback function", function(done) { + hooks.add("foo.A", function(payload, done) { + setTimeout(function() { + payload.order.push("A") + done() + },30) + }) + hooks.add("foo.B", function(payload) { payload.order.push("B") } ) + + let data = { order:[] }; + hooks.trigger("foo",data,err => { + data.order.should.eql(["A","B"]) + done(err); + }) + }) + + it("handler can use callback function - halt execution", function(done) { + hooks.add("foo.A", function(payload, done) { + setTimeout(function() { + payload.order.push("A") + done(false) + },30) + }) + hooks.add("foo.B", function(payload) { payload.order.push("B") } ) + + let data = { order:[] }; + hooks.trigger("foo",data,err => { + data.order.should.eql(["A"]) + err.should.be.false() + done(); + }) + }) + it("handler can use callback function - halt on error", function(done) { + hooks.add("foo.A", function(payload, done) { + setTimeout(function() { + done(new Error("test error")) + },30) + }) + hooks.add("foo.B", function(payload) { payload.order.push("B") } ) + + let data = { order:[] }; + hooks.trigger("foo",data,err => { + data.order.should.eql([]) + should.exist(err); + err.should.not.be.false() + done(); + }) + }) + + it("handler be an async function", function(done) { + hooks.add("foo.A", async function(payload) { + return new Promise(resolve => { + setTimeout(function() { + payload.order.push("A") + resolve() + },30) + }); + }) + hooks.add("foo.B", function(payload) { payload.order.push("B") } ) + + let data = { order:[] }; + hooks.trigger("foo",data,err => { + data.order.should.eql(["A","B"]) + done(err); + }) + }) + + it("handler be an async function - halt execution", function(done) { + hooks.add("foo.A", async function(payload) { + return new Promise(resolve => { + setTimeout(function() { + payload.order.push("A") + resolve(false) + },30) + }); + }) + hooks.add("foo.B", function(payload) { payload.order.push("B") } ) + + let data = { order:[] }; + hooks.trigger("foo",data,err => { + data.order.should.eql(["A"]) + done(err); + }) + }) + it("handler be an async function - halt on error", function(done) { + hooks.add("foo.A", async function(payload) { + return new Promise((resolve,reject) => { + setTimeout(function() { + reject(new Error("test error")) + },30) + }); + }) + hooks.add("foo.B", function(payload) { payload.order.push("B") } ) + + let data = { order:[] }; + hooks.trigger("foo",data,err => { + data.order.should.eql([]) + should.exist(err); + err.should.not.be.false() + done(); + }) + }) +});