@flowfuse/nr-assistant
Version:
FlowFuse Node-RED Expert plugin
845 lines (774 loc) • 37.1 kB
JavaScript
import { ExpertAutomations } from './expertAutomations.js'
function debounce (func, wait) {
let timeout
return function () {
const context = this; const args = arguments
const later = function () {
timeout = null
func.apply(context, args)
}
clearTimeout(timeout)
timeout = setTimeout(later, wait)
}
}
// Copied from Node-RED core editor-client/src/js/ui/utils.js
const loggingLevels = {
off: 1,
fatal: 10,
error: 20,
warn: 30,
info: 40,
debug: 50,
trace: 60,
audit: 98,
metric: 99
}
const logLevelLabels = {
[loggingLevels.fatal]: 'fatal',
[loggingLevels.error]: 'error',
[loggingLevels.warn]: 'warn',
[loggingLevels.info]: 'info',
[loggingLevels.debug]: 'debug',
[loggingLevels.trace]: 'trace',
[loggingLevels.audit]: 'audit',
[loggingLevels.metric]: 'metric'
}
const getNearestLoggingLevel = (level, fallback = logLevelLabels[loggingLevels.debug]) => {
// if level == one of the strings, just return it
const logLabels = Object.values(logLevelLabels)
if (typeof level === 'string' && logLabels.includes(level)) {
return level
}
if (loggingLevels[level]) {
return loggingLevels[level]
}
const levelNo = +level
if (isNaN(levelNo)) {
return fallback
}
let nearestLevel = 0
Object.values(loggingLevels).forEach(l => {
if (Math.abs(levelNo - l) < Math.abs(levelNo - nearestLevel)) {
nearestLevel = l
}
})
return logLevelLabels[nearestLevel] || fallback
}
const hasProperty = (obj, prop) => !!(obj && Object.prototype.hasOwnProperty.call(obj, prop))
export class ExpertComms {
/** @type {import('node-red').NodeRedInstance} */
RED = null
assistantOptions = {}
expertSupportedFeatures = {}
MESSAGE_SOURCE = 'nr-assistant'
MESSAGE_TARGET = 'flowfuse-expert'
MESSAGE_SCOPE = 'flowfuse-expert'
/** @type {ExpertAutomations} */
nrAutomations = new ExpertAutomations() // will set RED instance later in init
/**
* targetOrigin is set to '*' by default, which allows messages to be sent and received from any origin.
* This is fine for the initial handshake with the FF Expert (will change to the origin of the expert page once it is loaded)
*
* @type {string}
*/
targetOrigin = '*'
/**
* Define supported actions and their parameter schemas
*/
supportedActions = {
'core:manage-palette': {
params: {
type: 'object',
properties: {
view: {
type: 'string',
enum: ['nodes', 'install'],
default: 'install'
},
filter: {
description: 'Optional filter string. e.g. `"node-red-contrib-s7","node-red-contrib-other"` to pre-filter the palette view',
type: 'string'
}
},
required: ['filter']
}
},
'custom:import-flow': {
params: {
type: 'object',
properties: {
flow: {
type: 'string',
description: 'The flow JSON to import'
},
addFlow: {
type: 'boolean',
description: 'Whether to add the flow to the current workspace tab (false) or create a new tab (true). Default: false'
}
},
required: ['flow']
}
},
'custom:close-search': { params: null },
'custom:close-typeSearch': { params: null },
'custom:close-actionList': { params: null },
...this.nrAutomations.supportedActions
}
/**
* A mapping of Node-RED core events to their respective handler logic.
*
* This map acts as a router for the assistant's event listeners:
* - Functions: Executed immediately when the event fires (e.g., notifying the parent of UI state).
* - Strings: Represent the name of a method within this class to be invoked (e.g., refreshing the palette).
* The method name being referenced must be appended with 'notify'
*
* @type {Object.<string, Function|string>}
*/
nodeRedEventsMap = {
// palette changes
'registry:node-set-added': 'notifyPaletteChange',
'registry:node-set-removed': 'notifyPaletteChange',
'registry:node-set-disabled': 'notifyPaletteChange',
'registry:node-set-enabled': 'notifyPaletteChange',
// selection changes
'view:selection-changed': 'notifySelectionChanged',
// workspace changes
'workspace:change': 'notifyWorkspaceChange',
'flows:loaded': 'notifyWorkspaceChange'
}
/**
* A mapping of FlowFuse Expert events to their respective handler logic.
*
* This map acts as a router for the expert's event listeners:
* - Functions: Executed immediately when the event fires (e.g., notifying the parent of UI state).
* - Strings: Represent the name of a method within this class to be invoked (e.g., refreshing the palette).
* The method name being referenced must be appended with 'handle'
*
* @type {Object.<string, Function|string>}
*/
commandMap = {
'expert-ready': 'handleExpertReady',
'get-assistant-version': ({ event, type, action, params } = {}) => {
// handle version request
this.postReply({ type, version: this.assistantOptions.assistantVersion, success: true }, event)
},
'get-assistant-features': ({ event, type, action, params } = {}) => {
// handle features request
this.postReply({ type, features: this.features, success: true }, event)
},
'get-supported-actions': ({ event, type, action, params } = {}) => {
// handle supported actions request
this.postReply({ type, supportedActions: this.supportedActions, success: true }, event)
},
'get-palette': async ({ event, type, action, params } = {}) => {
// handle palette request
this.postReply({ type: 'set-palette', palette: await this.getPalette(), success: true }, event)
},
'invoke-action': 'handleActionInvocation',
'get-selection': 'handleGetSelection',
'register-event-listeners': 'handleRegisterEvents',
'debug-log-context-registered': 'handleDebugLogContextRegistration',
'debug-log-context-get-entries': 'handleDebugLogContextGetEntries'
}
/**
* A set of flags and features supported by this plugin version.
* These should be used by the FlowFuse Expert to determine what functionality can be leveraged.
*/
get features () {
return {
commands: Object.fromEntries(Object.entries(this.commandMap).map(([name, value]) => [name, { enabled: true }])),
actions: Object.fromEntries(Object.entries(this.supportedActions).map(([name, value]) => [name, { enabled: true }])),
registeredEvents: Object.fromEntries(Object.entries(this.nodeRedEventsMap).map(([name, value]) => [name, { enabled: true }])), // list of Node-RED events registered to be echoed to the expert
dynamicEventRegistration: { enabled: true }, // supports dynamic registration of Node-RED events to be listened to
flowSelection: { enabled: true }, // supports passing the flow selection
flowImport: { enabled: true }, // supports importing flows
paletteManagement: { enabled: true }, // supports palette management actions
debugLogContext: { enabled: true } // supports providing debug log context to the expert
}
}
init (RED, assistantOptions) {
/** @type {import('node-red').NodeRedInstance} */
this.RED = RED
this.RED.nrAssistant = this
this.assistantOptions = assistantOptions
this.nrAutomations.init(this, RED)
if (!window.parent?.postMessage || window.self === window.top) {
console.warn('Parent window not detected - certain interactions with the FlowFuse Expert will not be available')
return
}
this.setNodeRedEventListeners()
this.setupMessageListeners()
this.notifyWorkspaceChange()
// Notify the parent window that the assistant is ready
this.postParent({
type: 'assistant-ready',
version: this.assistantOptions.assistantVersion,
enabled: this.assistantOptions.enabled,
standalone: this.assistantOptions.standalone,
nodeRedVersion: this.RED.settings.version,
nodeRedUpdatesAvailable: this.RED.palette?.editor?.getAvailableUpdates ? this.RED.palette.editor.getAvailableUpdates() : null,
features: this.features
})
// Hook into Node-RED's FrontEnd `debugPostProcessMessage` hook to add an "Add to context" button to debug log
// entries in the debug sidebar, allowing users to easily add relevant debug messages to the FlowFuse Expert context.
const allowHook = this.assistantOptions.enabled && this.assistantOptions.standalone !== true
const hookValid = RED.hooks.isKnownHook ? RED.hooks.isKnownHook('debugPostProcessMessage') : false
let alreadyHooked = false
if (allowHook && hookValid && !alreadyHooked) {
alreadyHooked = true
const that = this
// listen for `flows:loaded` (i.e. editor is ready) then hook the debug clear button
const hookDebugClearButton = function () {
if ($('#red-ui-sidebar-debug-clear').length) {
$('#red-ui-sidebar-debug-clear').on('click', function () {
that.postParent({
type: 'debug-log-context-clear',
data: {}
})
})
that.RED.events.off('flows:loaded', hookDebugClearButton)
}
}
that.RED.events.on('flows:loaded', hookDebugClearButton)
// Hook into the debug message post-processing to add our "Add to context" button
RED.hooks.add('debugPostProcessMessage.nr-assistant', function (processedMessage) {
try {
const { message, element, payload } = processedMessage
const metaRowTools = element && element.find('.red-ui-debug-msg-meta .red-ui-debug-msg-tools')
if (!metaRowTools || !metaRowTools.length) {
return // can't find the tools container, so we can't add our button
}
// Check if the button already exists to avoid adding multiple buttons
if (metaRowTools.find('button.ff-expert-debug-context').length) {
return // button already exists, no need to add another
}
// Create button and add data/click handler
const buttonEl = $('<button class="ff-expert-debug-context red-ui-button red-ui-button-small"><i class="fa fa-plus"></i></button>')
RED.popover.tooltip(buttonEl, 'Add to FlowFuse Expert context')
buttonEl.css('cursor', 'pointer')
// Here we generate a unique id here and store it as a data attribute on the button, along with the relevant data
// (jquery data is used for storage as it allows storing complex objects, whereas data attributes can only store strings)
const uuid = `${message._source?.id || message.id}:${Math.random().toString(16).slice(2)}${Date.now().toString(16)}`
buttonEl.attr('data-ff-expert-debug-uuid', uuid)
buttonEl.data('ff-expert-debug-data', { message, payload })
buttonEl.on('click', function (evt) {
evt.preventDefault()
evt.stopPropagation()
// toggle off if already selected
if (buttonEl.hasClass('selected')) {
const uuid = buttonEl.attr('data-ff-expert-debug-uuid')
that.postParent({
type: 'debug-log-context-remove',
debugLog: [{ uuid }]
})
return
}
// send formatted debug message to the parent
const entry = that.formatDebugMessage(uuid, message, payload)
that.postParent({
type: 'debug-log-context-add',
debugLog: [entry]
})
})
metaRowTools.append(buttonEl)
} catch (_err) {
// fallback to original function if any error is encountered
this.debug('Error adding debug log context button, falling back to original element creation', _err)
}
})
}
}
setupMessageListeners () {
// Listen for postMessages from the parent window
window.addEventListener('message', async (event) => {
// prevent own messages being processed (unless they are debug test messages)
if (event.source === window.self) {
return
}
const { type, action, params, target, source, scope, correlationId } = event.data || {}
// Ensure scope and source match expected values
if (target !== this.MESSAGE_SOURCE || source !== this.MESSAGE_TARGET || scope !== this.MESSAGE_SCOPE) {
return
}
// Setting target origin for future calls
if (this.targetOrigin === '*') {
this.targetOrigin = event.origin
}
this.debug('Received postMessage:', event.data)
const payload = {
event,
type,
action,
params,
correlationId
}
for (const eventName in this.commandMap) {
const handler = this.commandMap[eventName]
// identify if the hander is a string, function or async function
let handlerType = typeof handler
if (handlerType === 'function' && handler.constructor.name === 'AsyncFunction') {
handlerType = 'asyncfunction'
}
if (type === eventName && handlerType === 'function') {
return this.commandMap[eventName](payload)
}
if (type === eventName && handlerType === 'asyncfunction') {
return await this.commandMap[eventName](payload)
}
if (
type === eventName &&
typeof this.commandMap[eventName] === 'string' &&
this.commandMap[eventName] in this
) {
return this[this.commandMap[eventName]](payload)
}
}
// handles unknown message type
this.postReply({ type: 'error', error: 'unknown-type', data: event.data }, event)
}, false)
}
/**
* Register Node-RED events to be listened to and echoed to the FlowFuse Expert
* @param {Record<string,string>} events - Key is Node-RED event to register, Value is event to emit back
*/
handleRegisterEvents ({ event, params }) {
if (!params || typeof params !== 'object') {
return
}
for (const key in params) {
const eventMapping = params[key] // FF Expert will send { eventName: {nodeRedEvent: 'editor:open', future: xxx} }
const callBackName = key // the key is the FF event name to call back on
const nodeRedEvent = eventMapping.nodeRedEvent // the NR event to subscribe to
if (callBackName && nodeRedEvent) {
if (hasProperty(this.nodeRedEventsMap, nodeRedEvent)) {
continue // nodeRedEvent already registered
}
this.nodeRedEventsMap[nodeRedEvent] = callBackName
this.RED.events.on(nodeRedEvent, (eventData) => {
this.postParent({ type: callBackName, eventMapping, eventData })
})
}
}
// send updated feature list
this.postReply({ type: 'set-assistant-features', features: this.features }, event)
}
/**
* This function syncs the selected debug log entries in the Node-RED debug sidebar with the FlowFuse Expert.
* It should be called when the user clears logs from chat context
*/
handleDebugLogContextRegistration ({ event, params }) {
if (!params || typeof params !== 'object') {
return
}
const { register } = params
if (register) {
// update class on the debug panel
// remove all .selected'
$('button.ff-expert-debug-context').removeClass('selected')
register.forEach(uuid => {
// find the element with data-ff-expert-debug-uuid=uuid and add class .selected
$(`button[data-ff-expert-debug-uuid="${uuid}"]`).addClass('selected')
})
}
}
/**
* This function retrieves the debug log entries from the Node-RED debug sidebar and sends them to the FlowFuse Expert.
* It supports filtering by log level and visibility in the UI.
* This is typically commanded by the user in the Chat UI where they may request to add debug log entries to the context.
*/
handleDebugLogContextGetEntries ({ event, params }) {
const { visibleOnly = true, fatal = true, error = true, warn = true, info = true, debug = true, trace = true } = params || {}
const wantLevels = []
if (fatal) wantLevels.push('fatal')
if (error) wantLevels.push('error')
if (warn) wantLevels.push('warn')
if (info) wantLevels.push('info')
if (debug) wantLevels.push('debug')
if (trace) wantLevels.push('trace')
const isElementInView = (jElement) => {
if (jElement.length === 0) {
return false
}
if (!visibleOnly) {
return true
}
if (jElement.hasClass('hide')) {
return false // not visible, skip this entry
}
const rect = jElement[0].getBoundingClientRect()
if (!rect?.width || !rect?.height) {
return false
}
return (
rect.top >= 0 &&
rect.left >= 0 &&
rect.bottom <= $(window).height() &&
rect.right <= $(window).width()
)
}
// first remove class .selected from all entries. It will be re-added once the expert responds with the list uuids it has registered.
$('button.ff-expert-debug-context').removeClass('selected')
const filteredEntries = []
// get buttons `.red-ui-debug-content-list button.ff-expert-debug-context` in the debug sidebar
// but dont include any with `.hide` on the parent `.red-ui-debug-msg` element, as those are not visible
$('.red-ui-debug-content-list button.ff-expert-debug-context').each((i, el) => {
const expertToolButtonEl = $(el)
const parent = expertToolButtonEl.closest('div.red-ui-debug-msg')
if (!isElementInView(parent)) {
return // hidden or not visible in the viewport, skip this entry
}
const uuid = expertToolButtonEl.attr('data-ff-expert-debug-uuid')
if (uuid) {
const data = expertToolButtonEl.data('ff-expert-debug-data')
if (!data) return
const { message, payload } = data
const entry = this.formatDebugMessage(uuid, message, payload)
const level = entry?.level
if (!wantLevels.includes(level)) {
return // not a level we want to include, skip this entry
}
filteredEntries.push(entry)
}
})
this.postReply({ type: 'debug-log-context-add', debugLog: filteredEntries }, event)
}
setNodeRedEventListeners () {
Object.keys(this.nodeRedEventsMap).forEach(eventName => {
if (typeof this.nodeRedEventsMap[eventName] === 'function') {
this.RED.events.on(eventName, this.nodeRedEventsMap[eventName].bind(this))
}
if (typeof this.nodeRedEventsMap[eventName] === 'string' && this.nodeRedEventsMap[eventName] in this) {
this.RED.events.on(eventName, this[this.nodeRedEventsMap[eventName]].bind(this))
}
})
}
/**
* FlowFuse Expert Node-RED event notifiers
*/
async notifyPaletteChange () {
this.postParent({
type: 'set-palette',
palette: await this.getPalette()
}, true) // debounced
}
notifySelectionChanged ({ nodes }) {
if (nodes && Array.isArray(nodes)) {
this.postParent({
type: 'set-selection',
selection: this.formatSelectedNodes(nodes)
})
} else {
this.postParent({
type: 'set-selection',
selection: []
})
}
}
notifyWorkspaceChange () {
const activeTab = this.RED.workspaces?.active?.()
const tab = activeTab ? (this.RED.nodes?.workspace(activeTab) || this.RED.nodes?.subflow(activeTab)) : null
const label = tab?.label || tab?.name
if (!label) { return }
this.postParent({
type: 'nr-assistant/workspace:change',
tab: { id: tab.id, label }
})
}
/**
* FlowFuse Expert message handlers
*/
async handleActionInvocation ({ event, type, action, params, correlationId } = {}) {
this.debug(`Received request to invoke action "${action}" with params`, params)
// handle action invocation requests (must be registered actions in supportedActions)
if (typeof action !== 'string') {
return
}
if (!this.supportedActions[action]) {
console.warn(`Action "${action}" is not permitted to be invoked via postMessage`)
this.postReply({ type, action, error: 'unknown-action', correlationId }, event)
return
}
// Validate params against permitted schema (native/naive parsing for now - may introduce a library later if more complex schemas are needed)
const actionSchema = this.supportedActions[action].params
if (actionSchema) {
const validation = this.validateActionParams(params, actionSchema)
if (!validation || !validation.valid) {
console.warn(`Params for action "${action}" did not validate against the expected schema`, params, actionSchema, validation)
this.postReply({ type, action, error: validation.error || 'invalid-parameters', correlationId }, event)
return
}
}
const actionNamespace = action.split('/')[0]
switch (action) {
case 'custom:close-search':
this.RED.search.hide()
this.postReply({ type, action, acknowledged: true, correlationId }, event)
return
case 'custom:close-typeSearch':
this.RED.typeSearch.hide()
this.postReply({ type, action, acknowledged: true, correlationId }, event)
return
case 'custom:close-actionList':
this.RED.actionList.hide()
this.postReply({ type, action, acknowledged: true, correlationId }, event)
return
case 'custom:import-flow':
// import-flow is a custom action - handle it here directly
try {
this.nrAutomations.importFlow(params.flow, { addFlow: params.addFlow })
this.postReply({ type, success: true, correlationId }, event)
} catch (err) {
this.RED.notify('Import failed:' + err.message, 'error')
this.postReply({ type, error: err?.message, correlationId }, event)
}
return
case 'core:manage-palette': {
// Delegate to automation for centralised handling
const result = { handled: false, success: false, noReply: false, correlationId }
try {
await this.nrAutomations.invokeAction('automation/open-palette-manager', { event, params }, result)
this.postReply({ type, action, success: true, ...result }, event)
} catch (err) {
result.error = err.message
this.postReply({ type, action, ...result, success: false }, event)
}
return
}
default: {
const result = { handled: false, success: false, noReply: false, correlationId }
try {
if (actionNamespace === 'automation') {
// Handle supported automated actions
await this.nrAutomations.invokeAction(action, { event, params }, result)
} else {
// Handle supported native Node-RED actions
this.RED.actions.invoke(action, params)
result.handled = true
result.success = true
}
if (result.noReply === true) {
// if the action took care of business or has no reply - i.e. dont reply!
return
}
this.postReply({ type, action, success: true, ...result }, event)
} catch (err) {
result.error = err.message
result.exception = err
this.postReply({ type, action, ...result, success: false }, event)
}
}
}
}
handleGetSelection ({ event }) {
let selection = []
const selectedNodes = this.RED.view.selection()
if (selectedNodes && Array.isArray(selectedNodes)) {
selection = this.formatSelectedNodes(this.RED.view.selection())
}
this.postReply({ type: 'set-selection', selection }, event)
}
async getPalette () {
return this.nrAutomations.getPalette()
}
handleExpertReady ({ event, params }) {
// Expert is ready. `params` should contain flags for features the expert supports, which
// can be used to conditionally enable/disable functionality in the assistant.
// For now, the only functionality we need to gate is showing the "Add to FF Expert context" buttons
// in the debug sidebar, which we don't want to show if the expert does not support debug log context!
const { supportedFeatures } = params || {}
this.expertSupportedFeatures = { ...supportedFeatures }
// if the expert supports debug log context, then we should allow the buttons to be shown
// by setting --ff-feature--display-debug-log-context: unset in the CSS
if (supportedFeatures.debugLogContext && supportedFeatures.debugLogContext.enabled) {
document.documentElement.style.setProperty('--ff-feature--display-debug-log-context', 'unset')
}
}
formatSelectedNodes (nodes, includeModuleConfig = true) {
return this.RED.nodes.createExportableNodeSet(nodes, { includeModuleConfig })
}
/**
* Recursively apply default values from a JSON schema to the data object.
* Handles nested object properties.
* @param {*} data - the data object to apply defaults to (mutated in place)
* @param {object} schema - JSON schema with potential default values
* @returns {*} the data object with defaults applied
*/
applySchemaDefaults (data, schema) {
if (!schema || typeof schema !== 'object') {
return data
}
if (schema.type === 'object' && schema.properties && typeof data === 'object' && data !== null && !Array.isArray(data)) {
for (const [propName, propSchema] of Object.entries(schema.properties)) {
if (propSchema.default !== undefined && !(propName in data)) {
data[propName] = propSchema.default
}
if (propName in data) {
this.applySchemaDefaults(data[propName], propSchema)
}
}
} else if (schema.type === 'array' && schema.items && Array.isArray(data)) {
for (const item of data) {
this.applySchemaDefaults(item, schema.items)
}
}
return data
}
validateActionParams (data, schema) {
// apply defaults (including nested) before validation
this.applySchemaDefaults(data, schema)
const errors = []
const validate = (data, schema, path, errors) => {
const allowedTypes = schema.type
? (Array.isArray(schema.type) ? schema.type : [schema.type])
: null
let actualType = data === null ? 'null' : typeof data
if (Array.isArray(data)) actualType = 'array'
if (allowedTypes && !allowedTypes.includes(actualType)) {
errors.push(`${path}: is not of a type(s) ${allowedTypes.join(' or ')}`)
return
}
if (actualType === 'object' && allowedTypes?.includes('object')) {
if (Array.isArray(schema.required)) {
for (const reqProp of schema.required) {
if (!(reqProp in data)) {
errors.push(`${path ? `${path}.${reqProp}` : reqProp}: is required`)
}
}
}
if (schema.properties) {
for (const [propName, propSchema] of Object.entries(schema.properties)) {
if (!(propName in data)) continue
validate(data[propName], propSchema, path ? `${path}.${propName}` : propName, errors)
}
}
} else if (actualType === 'array' && allowedTypes?.includes('array')) {
if (schema.items) {
for (let i = 0; i < data.length; i++) {
validate(data[i], schema.items, path ? `${path}[${i}]` : `[${i}]`, errors)
}
}
}
if (schema.enum && !schema.enum.includes(data)) {
errors.push(`${path}: is not one of enum values: ${schema.enum.join(', ')}`)
}
}
validate(data, schema, '', errors)
if (errors.length > 0) {
return { valid: false, error: errors.join('; ') }
}
return { valid: true }
}
debug (...args) {
if (this.RED.nrAssistant?.DEBUG) {
const scriptName = 'assistant-index.html.js' // must match the sourceURL set in the script below
const stackLine = new Error().stack.split('\n')[2].trim()
const match = stackLine.match(/\(?([^\s)]+):(\d+):(\d+)\)?$/) || stackLine.match(/@?([^@]+):(\d+):(\d+)$/)
const file = match?.[1] || 'anonymous'
const line = match?.[2] || '1'
const col = match?.[3] || '1'
let link = `${window.location.origin}/${scriptName}:${line}:${col}`
if (/^VM\d+$/.test(file)) {
link = `debugger:///${file}:${line}:${col}`
} else if (file !== 'anonymous' && file !== '<anonymous>' && file !== scriptName) {
link = `${file}:${line}:${col}`
if (!link.startsWith('http') && !link.includes('/')) {
link = `${window.location.origin}/${link}`
}
}
// eslint-disable-next-line no-console
console.log('[nr-assistant]', ...args, `\n at ${link}`)
}
}
/**
* Internal helper to send a formatted message to a target window
*/
_post (payload, targetWindow) {
if (targetWindow && typeof targetWindow.postMessage === 'function') {
targetWindow.postMessage({
...payload,
source: this.MESSAGE_SOURCE,
scope: this.MESSAGE_SCOPE,
target: this.MESSAGE_TARGET
}, this.targetOrigin)
} else {
console.warn('Unable to post message, target window not available', payload)
}
}
_postDebounced = debounce(this._post, 150)
postParent (payload = {}, debounce) {
if (debounce) {
this.debug('Posting parent message (debounced)', payload)
this._postDebounced(payload, window.parent)
} else {
this.debug('Posting parent message', payload)
this._post(payload, window.parent)
}
}
postReply (payload, event) {
this.debug('Posting reply message:', payload)
this._post(payload, event.source)
}
/**
* Internal helper to format a debug log entry based on the options and message provided by the Node-RED debug sidebar,
* enriched with metadata about the source node and workspace.
* @param {string} uuid - a unique identifier for this debug log entry, used to manage selection state between Node-RED and the FlowFuse Expert
* @param {*} message - the debug message object
* @param {*} payload - the debug payload as sent from backend (rehydrated)
* @returns an object representing the debug log entry, enriched with metadata about its source and context, suitable for sending to the FlowFuse Expert
*/
formatDebugMessage (uuid, message, payload) {
const getInfo = (id, name, type, z) => {
const result = { id, name: name || '', type, z }
const node = this.RED.nodes.node(id)
const isNodeSubFlowInstance = (node?.type || '').startsWith('subflow:')
const flow = !node ? this.RED.nodes.workspace(id) : null
if (node) {
result.type = node.type || result.type
result.name = node.name || result.name
result.z = node.z || result.z
if (isNodeSubFlowInstance) {
result.isSubflowInstance = true
result.subflowTemplateId = node.type.replace('subflow:', '')
const subflowTemplate = this.RED.nodes.subflow(result.subflowTemplateId)
result.subFlowTemplateName = subflowTemplate?.name || ''
}
if (node._def?.category === 'config') {
result.isConfig = true
}
} else if (flow) {
result.type = flow.type || result.type || 'tab'
result.name = flow.name || result.name
if (!flow.z && !result.z) {
result.z = flow.id // set to self (to aid in identification in the expert)
}
}
return result
}
const _source = message._source || {}
const _sourceId = message._alias || _source._alias || _source.id || message.id || ''
const _hierarchy = _source.pathHierarchy || [{ id: _sourceId }]
const hierarchy = _hierarchy.map(e => getInfo(e.id, e.name || e.label, e.type, e.z))
const source = hierarchy.pop()
const ancestors = hierarchy
const metadata = {
format: message.format,
timestamp: message.timestamp || Date.now(),
path: message.path || ''
}
if (hasProperty(message, 'topic')) {
metadata.topic = message.topic // sometimes the message has a topic
}
if (hasProperty(message, 'property')) {
metadata.property = message.property // if the message has a property, it indicates what property of the message is being debugged (e.g. msg.payload, msg.payload.value, etc.)
}
const event = {
uuid,
level: getNearestLoggingLevel(message.level, 'debug'), // default to 'debug' if no level is provided
data: payload, // the data in the debug message, as sent from the backend (rehydrated)
source, // info about the node that generated the debug message
ancestors, // info about the parent nodes of the source node, up to the workspace level
metadata
}
return event
}
}