Merge pull request #5495 from node-red/5489-frontend-hooks

Add frontend pre and post debug message hooks
pull/5499/head^2
Nick O'Leary 2026-02-25 15:35:03 +00:00 committed by GitHub
commit d4819ddd47
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 215 additions and 90 deletions

View File

@ -1,135 +1,206 @@
RED.hooks = (function() {
RED.hooks = (function () {
// At the time of writing this PR, VALID_HOOKS were not enforced. There may be a good reason for this
// so the below flag has been added to permit this behaviour. If desired, this can be set to false to
// enforce that only known hooks can be added/triggered.
const knownHooksOnly = false
var VALID_HOOKS = [
const VALID_HOOKS = Object.freeze({
viewRemoveNode: true,
viewAddNode: true,
viewRemovePort: true,
viewAddPort: true,
viewRedrawNode: true,
debugPreProcessMessage: true,
debugPostProcessMessage: true
})
]
/**
* @typedef {keyof typeof VALID_HOOKS} HookId - A string literal type representing a hook identifier (sans label).
*
* @typedef {Object} HookItem - An item in the linked list of hooks for a given HookId
* @property {function} cb - The callback function to be called when the hook is triggered
* @property {HookItem|null} previousHook - The previous hook in the linked list
* @property {HookItem|null} nextHook - The next hook in the linked list
* @property {boolean} removed - Flag indicating if the hook has been removed
*
* @typedef {Record<HookId, HookItem|null>} Hooks - A mapping of HookIds to the head of their linked list of HookItems
*/
var hooks = { }
var labelledHooks = { }
/** @type {Hooks} - A mapping of HookIds to the head of their linked list of HookItems */
let hooks = {}
/** @type {Record<string, Record<HookId, HookItem>>} - A mapping of labels to their hooks */
let labelledHooks = {}
function add(hookId, callback) {
var parts = hookId.split(".");
var id = parts[0], label = parts[1];
const { label, id } = parseLabelledHook(hookId)
// if (VALID_HOOKS.indexOf(id) === -1) {
// throw new Error("Invalid hook '"+id+"'");
// }
if (label && labelledHooks[label] && labelledHooks[label][id]) {
throw new Error("Hook "+hookId+" already registered")
if (knownHooksOnly && !isKnownHook(id)) {
throw new Error("Invalid hook '" + id + "'")
}
if (label && labelledHooks[label] && labelledHooks[label][id]) {
throw new Error("Hook " + hookId + " already registered")
}
if (typeof callback !== "function") {
throw new Error("Invalid hook '" + hookId + "'. Callback must be a function")
}
var hookItem = {cb:callback, previousHook: null, nextHook: null }
var tailItem = hooks[id];
/** @type {HookItem} */
const hookItem = { cb: callback, previousHook: null, nextHook: null }
let tailItem = hooks[id]
if (tailItem === undefined) {
hooks[id] = hookItem;
hooks[id] = hookItem
} else {
while(tailItem.nextHook !== null) {
while (tailItem.nextHook !== null) {
tailItem = tailItem.nextHook
}
tailItem.nextHook = hookItem;
hookItem.previousHook = tailItem;
tailItem.nextHook = hookItem
hookItem.previousHook = tailItem
}
if (label) {
labelledHooks[label] = labelledHooks[label]||{};
labelledHooks[label][id] = hookItem;
labelledHooks[label] = labelledHooks[label] || {}
labelledHooks[label][id] = hookItem
}
}
function remove(hookId) {
var parts = hookId.split(".");
var id = parts[0], label = parts[1];
if ( !label) {
throw new Error("Cannot remove hook without label: "+hookId)
const { label, id } = parseLabelledHook(hookId)
if (!label) {
throw new Error("Cannot remove hook without label: " + hookId)
}
if (labelledHooks[label]) {
if (id === "*") {
// Remove all hooks for this label
var hookList = Object.keys(labelledHooks[label]);
for (var i=0;i<hookList.length;i++) {
removeHook(hookList[i],labelledHooks[label][hookList[i]])
const hookList = Object.keys(labelledHooks[label])
for (let i = 0; i < hookList.length; i++) {
removeHook(hookList[i], labelledHooks[label][hookList[i]])
}
delete labelledHooks[label];
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];
removeHook(id, labelledHooks[label][id])
delete labelledHooks[label][id]
if (Object.keys(labelledHooks[label]).length === 0) {
delete labelledHooks[label]
}
}
}
}
function removeHook(id,hookItem) {
var previousHook = hookItem.previousHook;
var nextHook = hookItem.nextHook;
/**
* Remove a hook from the linked list of hooks for a given id
* @param {HookId} id
* @param {HookItem} hookItem
* @private
*/
function removeHook(id, hookItem) {
let previousHook = hookItem.previousHook
let nextHook = hookItem.nextHook
if (previousHook) {
previousHook.nextHook = nextHook;
previousHook.nextHook = nextHook
} else {
hooks[id] = nextHook;
hooks[id] = nextHook
}
if (nextHook) {
nextHook.previousHook = previousHook;
nextHook.previousHook = previousHook
}
hookItem.removed = true;
hookItem.removed = true
if (!previousHook && !nextHook) {
delete hooks[id];
delete hooks[id]
}
}
function trigger(hookId, payload, done) {
var hookItem = hooks[hookId];
/**
* Trigger a hook, calling all registered callbacks in sequence.
* If any callback returns false, the flow is halted and no further hooks are called.
* @param {HookId} id The id of the hook to trigger (should not include a label - e.g. "viewAddNode", not "viewAddNode.myLabel")
* @param {*} payload The payload to be passed to each hook callback
* @param {function(?Error=):void} [done] Optional callback. If not provided, a Promise will be returned.
* @return {Promise|undefined} Returns a Promise if the done callback is not provided, otherwise undefined
*/
function trigger(id, payload, done) {
let hookItem = hooks[id]
if (!hookItem) {
if (done) {
done();
done()
return
} else {
return Promise.resolve()
}
return;
}
if (!done) {
return new Promise((resolve, reject) => {
invokeStack(hookItem, payload, function (err) {
if (err !== undefined && err !== false) {
if (!(err instanceof Error)) {
err = new Error(err)
}
err.hook = id
reject(err)
} else {
resolve(err)
}
})
})
} else {
invokeStack(hookItem, payload, done)
}
}
/**
* @private
*/
function invokeStack(hookItem, payload, done) {
function callNextHook(err) {
if (!hookItem || err) {
if (done) { done(err) }
return err;
done(err)
return
}
if (hookItem.removed) {
hookItem = hookItem.nextHook;
return callNextHook();
hookItem = hookItem.nextHook
callNextHook()
return
}
var callback = hookItem.cb;
const callback = hookItem.cb
if (callback.length === 1) {
try {
let result = callback(payload);
let result = callback(payload)
if (result === false) {
// Halting the flow
if (done) { done(false) }
return result;
done(false)
return
}
hookItem = hookItem.nextHook;
return callNextHook();
} catch(e) {
console.warn(e);
if (done) { done(e);}
return e;
if (result && typeof result.then === 'function') {
result.then(handleResolve, callNextHook)
return
}
hookItem = hookItem.nextHook
callNextHook()
} catch (e) {
done(e)
return
}
} else {
// There is a done callback
try {
callback(payload,function(result) {
if (result === undefined) {
hookItem = hookItem.nextHook;
callNextHook();
} else {
if (done) { done(result)}
}
})
} catch(e) {
console.warn(e);
if (done) { done(e) }
return e;
callback(payload, handleResolve)
} catch (e) {
done(e)
return
}
}
}
return callNextHook();
function handleResolve(result) {
if (result === undefined) {
hookItem = hookItem.nextHook
callNextHook()
} else {
done(result)
}
}
callNextHook()
}
function clear() {
@ -137,20 +208,48 @@ RED.hooks = (function() {
labelledHooks = {}
}
/**
* Check if a hook with the given id exists
* @param {string} hookId The hook identifier, which may include a label (e.g. "viewAddNode.myLabel")
* @returns {boolean}
*/
function has(hookId) {
var parts = hookId.split(".");
var id = parts[0], label = parts[1];
const { label, id } = parseLabelledHook(hookId)
if (label) {
return !!(labelledHooks[label] && labelledHooks[label][id])
}
return !!hooks[id]
}
return {
has: has,
clear: clear,
add: add,
remove: remove,
trigger: trigger
function isKnownHook(hookId) {
const { id } = parseLabelledHook(hookId)
return !!VALID_HOOKS[id]
}
})();
/**
* Split a hook identifier into its id and label components.
* @param {*} hookId A hook identifier, which may include a label (e.g. "viewAddNode.myLabel")
* @returns {{label: string, id: HookId}}
* @private
*/
function parseLabelledHook(hookId) {
if (typeof hookId !== "string") {
return { label: '', id: '' }
}
const parts = hookId.split(".")
const id = parts[0]
const label = parts[1]
return { label, id }
}
VALID_HOOKS['all'] = true // Special wildcard to allow hooks to indicate they should be triggered for all ids
return {
has,
clear,
add,
remove,
trigger,
isKnownHook
}
})()

View File

@ -392,12 +392,28 @@ RED.debug = (function() {
if (o) { stack.push(o); }
if (!busy && (stack.length > 0)) {
busy = true;
processDebugMessage(stack.shift());
setTimeout(function() {
busy = false;
handleDebugMessage();
}, 15); // every 15mS = 66 times a second
if (stack.length > numMessages) { stack = stack.splice(-numMessages); }
const message = stack.shift()
// call any preDebugLog hooks, allowing them to modify the message or block it from being displayed
RED.hooks.trigger('debugPreProcessMessage', { message }).then(result => {
if (result === false) {
return false; // A hook returned false - halt processing of this message
}
return processDebugMessage(message);
}).then(processArtifacts => {
if (processArtifacts === false) {
return false; // A hook returned false - halt processing of this message
}
const { message, element, payload } = processArtifacts || {};
return RED.hooks.trigger('debugPostProcessMessage', { message, element, payload });
}).catch(err => {
console.error("Error in debug process message hooks", err);
}).finally(() => {
setTimeout(function() {
busy = false;
handleDebugMessage();
}, 15); // every 15mS = 66 times a second
if (stack.length > numMessages) { stack = stack.splice(-numMessages); }
})
}
}
@ -519,10 +535,13 @@ RED.debug = (function() {
sourceId: sourceNode && sourceNode.id,
rootPath: path,
nodeSelector: config.messageSourceClick,
enablePinning: true
enablePinning: true,
tools: o.tools // permit preDebugLog hooks to add extra tools to the <debugMessage> element
});
// Do this in a separate step so the element functions aren't stripped
debugMessage.appendTo(el);
// add the meta row tools container, even if there are no tools, so that the postProcessDebugMessage hook can add tools
const tools = $('<span class="red-ui-debug-msg-tools button-group"></span>').appendTo(metaRow)
// NOTE: relying on function error to have a "type" that all other msgs don't
if (o.hasOwnProperty("type") && (o.type === "function")) {
var errorLvlType = 'error';
@ -534,7 +553,6 @@ RED.debug = (function() {
msg.addClass('red-ui-debug-msg-level-' + errorLvl);
$('<span class="red-ui-debug-msg-topic">function : (' + errorLvlType + ')</span>').appendTo(metaRow);
} else {
var tools = $('<span class="red-ui-debug-msg-tools button-group"></span>').appendTo(metaRow);
var filterMessage = $('<button class="red-ui-button red-ui-button-small"><i class="fa fa-caret-down"></i></button>').appendTo(tools);
filterMessage.on("click", function(e) {
e.preventDefault();
@ -590,6 +608,14 @@ RED.debug = (function() {
if (atBottom) {
messageList.scrollTop(sbc.scrollHeight);
}
// return artifacts to permit postProcessDebugMessage hooks to modify the message element, access the
// processed payload or otherwise modify the message after it has been generated.
return {
message: o, // original debug message object, useful for any hook that might have tagged additional info onto it
element: msg, // the top-level element for this debug message
payload // the reconstructed debug message
}
}
function clearMessageList(clearFilter, filteredOnly) {