Add RED.hooks engine

This commit is contained in:
Nick O'Leary 2020-07-30 17:52:11 +01:00
parent d57ec0cd53
commit bdd736315a
No known key found for this signature in database
GPG Key ID: 4F2157149161A6C9
7 changed files with 399 additions and 2 deletions

View File

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

View File

@ -57,6 +57,7 @@ function createNodeApi(node) {
log: {},
settings: {},
events: runtime.events,
hooks: runtime.hooks,
util: runtime.util,
version: runtime.version,
require: requireModule,

View File

@ -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() {

View File

@ -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<hookList.length;i++) {
removeHook(hookList[i],labelledHooks[label][hookList[i]])
}
delete labelledHooks[label];
} else if (labelledHooks[label][id]) {
removeHook(id,labelledHooks[label][id])
delete labelledHooks[label][id];
if (Object.keys(labelledHooks[label]).length === 0){
delete labelledHooks[label];
}
}
}
}
function removeHook(id,callback) {
let i = hooks[id].findIndex(hook => 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
}

View File

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

View File

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

View File

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