12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064 |
- const fs = require('fs')
- const path = require('path')
-
- const v = require('../../package.json').version
- const datastore = require('../store/data.js')
- const statestore = require('../store/state.js')
- const { appendTopic, addConnectionCredentials } = require('../utils/index.js')
-
- // from: https://stackoverflow.com/a/28592528/3016654
- function join (...paths) {
- return paths.map(function (element) {
- return element.replace(/^\/|\/$/g, '')
- }).join('/')
- }
-
- /**
- * Check if an object has a property
- * TODO: move to test-able utility lib
- * @param {Object} obj - Object to check for property
- * @param {String} prop - Property to check for
- * @returns {boolean}
- */
- function hasProperty (obj, prop) {
- return Object.prototype.hasOwnProperty.call(obj, prop)
- }
-
- module.exports = function (RED) {
- const express = require('express')
- const { Server } = require('socket.io')
-
- datastore.setConfig(RED)
- statestore.setConfig(RED)
-
- /**
- * @typedef {import('socket.io/dist').Socket} Socket
- * @typedef {import('socket.io/dist').Server} Server
- */
-
- // store state that can maintain cross re-deployments
- const uiShared = {
- app: null,
- httpServer: null,
- /** @type { Server } */
- ioServer: null,
- /** @type {Object.<string, Socket>} */
- connections: {},
- settings: {},
- contribs: {}
- }
-
- /**
- * Initialise the Express Server and SocketIO Server in Singleton Pattern
- * @param {Object} node - Node-RED Node
- * @param {Object} config - Node-RED Node Config
- */
- function init (node, config) {
- node.uiShared = uiShared // ensure we have a uiShared object on the node (for testing mainly)
-
- if (!config.acceptsClientConfig) {
- // for those upgrading, we need this for backwards compatibility
- config.acceptsClientConfig = ['ui-control', 'ui-notification']
- }
-
- if (!('includeClientData' in config)) {
- // for those upgrading, we need this for backwards compatibility
- config.includeClientData = true
- }
-
- // expose these properties at runtime
- node.acceptsClientConfig = config.acceptsClientConfig // which node types can be scoped to a specific client
- node.includeClientData = config.includeClientData // whether to include client data in msg payloads
-
- // eventually check if we have routes used, so we can support multiple base UIs
- if (!uiShared.app) {
- uiShared.app = RED.httpNode || RED.httpAdmin
- uiShared.httpServer = RED.server
-
- // Use the 'dashboard' settings if present, otherwise fallback
- // to node-red-dashboard 'ui' settings object.
- uiShared.settings = RED.settings.dashboard || RED.settings.ui || {}
-
- // Default no-op middleware
- uiShared.httpMiddleware = function (req, res, next) { next() }
- if (uiShared.settings.middleware) {
- if (typeof uiShared.settings.middleware === 'function' || Array.isArray(uiShared.settings.middleware)) {
- uiShared.httpMiddleware = uiShared.settings.middleware
- }
- }
-
- /**
- * Load in third party widgets
- */
- let packagePath, packageJson
- if (RED.settings?.userDir) {
- packagePath = path.join(RED.settings.userDir, 'package.json')
- packageJson = JSON.parse(fs.readFileSync(packagePath, 'utf8'))
- } else {
- node.log('Cannot import third party widgets. No access to Node-RED package.json')
- }
-
- if (packageJson && packageJson.dependencies) {
- Object.entries(packageJson.dependencies)?.filter(([packageName, _packageVersion]) => {
- return packageName.includes('node-red-dashboard-2-')
- }).map(([packageName, _packageVersion]) => {
- const modulePath = path.join(RED.settings.userDir, 'node_modules', packageName)
- const packagePath = path.join(modulePath, 'package.json')
- // get third party package.json
- const packageJson = JSON.parse(fs.readFileSync(packagePath, 'utf8'))
- if (packageJson?.['node-red-dashboard-2']) {
- // loop over object of widgets
- Object.entries(packageJson['node-red-dashboard-2'].widgets).forEach(([widgetName, widgetConfig]) => {
- uiShared.contribs[widgetName] = {
- package: packageName,
- name: widgetName,
- src: widgetConfig.output,
- component: widgetConfig.component
- }
- })
- }
- return packageJson
- })
- }
-
- /**
- * Configure Web Server to handle UI traffic
- */
-
- uiShared.app.use(config.path, uiShared.httpMiddleware, express.static(path.join(__dirname, '../../dist')))
-
- uiShared.app.get(config.path + '/_setup', uiShared.httpMiddleware, (req, res) => {
- let socketPath = join(RED.settings.httpNodeRoot, config.path, 'socket.io')
- // if no leading /, add one (happens sometimes depending on httpNodeRoot in settings.js)
- if (socketPath[0] !== '/') {
- socketPath = '/' + socketPath
- }
- let resp = {
- RED: {
- httpAdminRoot: RED.settings.httpAdminRoot,
- httpNodeRoot: RED.settings.httpNodeRoot
- },
- socketio: {
- path: socketPath
- }
- }
- // Hook API - onSetup(RED, config, req, res)
- RED.plugins.getByType('node-red-dashboard-2').forEach(plugin => {
- if (plugin.hooks?.onSetup) {
- const _resp = plugin.hooks.onSetup(config, req, res)
- resp = { ...resp, ..._resp }
- }
- })
- return res.json(resp)
- })
-
- // debugging endpoints
- uiShared.app.get(config.path + '/_debug/datastore/:itemid', uiShared.httpMiddleware, (req, res) => {
- return res.json(datastore.get(req.params.itemid))
- })
-
- uiShared.app.get(config.path + '/_debug/statestore/:itemid', uiShared.httpMiddleware, (req, res) => {
- return res.json(statestore.getAll(req.params.itemid))
- })
-
- // serve dashboard
- uiShared.app.get(config.path, uiShared.httpMiddleware, (req, res) => {
- res.sendFile(path.join(__dirname, '../../dist/index.html'))
- })
-
- uiShared.app.get(config.path + '/*', uiShared.httpMiddleware, (req, res) => {
- res.sendFile(path.join(__dirname, '../../dist/index.html'))
- })
-
- node.log(`Node-RED Dashboard 2.0 (v${v}) started at ${config.path}`)
-
- /**
- * Create IO Server for comms between Node-RED and UI
- */
- if (RED.settings.httpNodeRoot !== false) {
- const root = RED.settings.httpNodeRoot || '/'
- const fullPath = join(root, config.path)
- const socketIoPath = join('/', fullPath, 'socket.io')
- /** @type {import('socket.io/dist').ServerOptions} */
- const serverOptions = {
- path: socketIoPath,
- maxHttpBufferSize: uiShared.settings.maxHttpBufferSize || 1e6 // SocketIO default size
- }
- // console.log('Creating socket.io server at path', socketIoPath) // disable - noisy in tests
- // store reference to the SocketIO Server
- uiShared.ioServer = new Server(uiShared.httpServer, serverOptions)
- uiShared.ioServer.setMaxListeners(0) // prevent memory leak warning // TODO: be more smart about this!
-
- if (typeof uiShared.settings.ioMiddleware === 'function') {
- uiShared.ioServer.use(uiShared.settings.ioMiddleware)
- } else if (Array.isArray(uiShared.settings.ioMiddleware)) {
- uiShared.settings.ioMiddleware.forEach(function (ioMiddleware) {
- uiShared.ioServer.use(ioMiddleware)
- })
- } else {
- uiShared.ioServer.use(function (socket, next) {
- if (socket.client.conn.request.url.indexOf('transport=websocket') !== -1) {
- // Reject direct websocket requests
- socket.client.conn.close()
- return
- }
- if (socket.handshake.xdomain === false) {
- return next()
- } else {
- socket.disconnect(true)
- }
- })
- }
- const bindOn = RED.server ? 'bound to Node-RED port' : 'on port ' + node.port
- node.log('Created socket.io server ' + bindOn + ' at path ' + socketIoPath)
- } else {
- node.warn('Cannot create UI Base node when httpNodeRoot set to false')
- }
- }
- }
-
- /**
- * Close the SocketIO Server
- */
- function close (node, done) {
- if (!uiShared.ioServer) {
- done()
- return
- }
-
- // determine if any ui-pages are left, if so, don't close the server
- const baseNodes = []
- const pageNodes = []
- const themes = []
- RED.nodes.eachNode(n => {
- if (n.type === 'ui-page' || n.type === 'ui-link') {
- pageNodes.push(n)
- } else if (n.type === 'ui-base' && n.id !== node.id) {
- baseNodes.push(n)
- } else if (n.type === 'ui-theme') {
- themes.push(n)
- }
- })
-
- if (pageNodes.length > 0) {
- // there are still ui-pages, so don't close the server
- done()
- return
- }
- node.ui.pages.clear()// ensure we clear out any pages that may have been left over
- // since there are no pages, we can assume widgets and groups are also gone
- node.ui.widgets.clear()
- node.ui.groups.clear()
-
- if (baseNodes.length > 0) {
- // there are still other ui-base nodes, don't close the server
- done()
- return
- }
-
- // as there are no more instances of ui-page and this is the last ui-base, close the server
- uiShared.ioServer.removeAllListeners()
- uiShared.ioServer.disconnectSockets(true)
- // tidy up
- if (themes.length === 0) {
- node.ui.themes.clear()
- }
- node.ui.dashboards.clear() // ensure we clear out any dashboards that may have been left over
- node.uiShared = null // remove reference to ui object
- done && done()
- }
-
- /**
- * UI Base Node Constructor. Called each time Node-RED deploy creates / recreates a u-base node.
- * * _whether this constructor is called depends on if there are any changes to THIS node_
- * * _A full Deploy will always call this function as every node is destroyed and re-created_
- * @param {Object} n - Node-RED node configuration as entered in the nodes editor
- */
- function UIBaseNode (n) {
- RED.nodes.createNode(this, n)
- const node = this
-
- node._created = Date.now()
-
- n.root = RED.settings.httpNodeRoot || '/'
-
- /** @type {Object.<string, Socket>} */
- // node.connections = {} // store socket.io connections for this node
- // // re-map existing connections for this base node
- for (const id in uiShared.connections) {
- const socket = uiShared.connections[id]
- if (uiShared.connections[id]._baseId === node.id) {
- // re establish event handlers
- socket.on('widget-send', onSend.bind(null, socket))
- socket.on('widget-action', onAction.bind(null, socket))
- socket.on('widget-change', onChange.bind(null, socket))
- socket.on('widget-load', onLoad.bind(null, socket))
- }
- }
- /** @type {NodeJS.Timeout} */
- node.emitConfigRequested = null // used to debounce requests to emitConfig
-
- // Configure & Run Express Server
- init(node, n)
-
- /**
- * Emit an event to all connected UIs
- * @param {String} event
- * @param {Object} msg
- * @param {Object} wNode - the Node-RED node that is emitting the event
- */
- function emit (event, msg, wNode) {
- Object.values(uiShared.connections).forEach(conn => {
- if (canSendTo(conn, wNode, msg)) {
- conn.emit(event, msg)
- }
- })
- }
-
- // surface this so that other nodes can emit messages directly
- node.emit = emit
-
- /**
- * Checks, given a received msg, and the associated SocketIO connection
- * whether the msg has been configured to only be sent to particular connections
- * @param {*} conn - SocketIO Connection Object
- * @param {*} wNode - The Node-RED node we are sending this to
- * @param {*} msg - The msg to be sent
- * @returns {Boolean} - Whether the msg can be sent to this connection
- */
- function canSendTo (conn, wNode, msg) {
- const nodeAllowsConstraints = wNode ? n.acceptsClientConfig?.includes(wNode.type) : true
- return (nodeAllowsConstraints && isValidConnection(conn, msg)) || !nodeAllowsConstraints
- }
-
- /**
- * Checks, given a received msg, and the associated SocketIO connection
- * whether the msg has been configured to only be sent to particular connections
- * @param {*} conn - SocketIO Connection Object
- * @param {*} msg -
- */
- function isValidConnection (conn, msg) {
- const checks = []
- // loop over plugins and check if any have defined a custom isValidConnection function
- // if so, use that to determine if the connection is valid
- for (const plugin of RED.plugins.getByType('node-red-dashboard-2')) {
- if (plugin.hooks?.onIsValidConnection) {
- checks.push(plugin.hooks.onIsValidConnection(conn, msg))
- }
- }
- // conduct the core check too
- if (msg._client?.socketId) {
- // if a particular socketid has been defined,
- // we only send comms on the connection that matches that id
- checks.push(msg._client?.socketId === conn.id)
- }
- // ensure all checks validate sending this
- return !checks.length || !checks.includes(false)
- }
-
- /**
- * Emit UI Config to all connected UIs
- * @param {Socket} socket - socket.io socket connecting to the server
- */
- function emitConfig (socket) {
- // loop over widgets - check statestore if we've had any dynamic properties set
- for (const [id, widget] of node.ui.widgets) {
- const state = statestore.getAll(id)
- if (state) {
- // merge the statestore with our props to account for dynamically set properties:
- widget.props = { ...widget.props, ...state }
- widget.state = { ...widget.state, ...state }
- }
- }
-
- // loop over pages - check statestore if we've had any dynamic properties set
- for (const [id, page] of node.ui.pages) {
- const state = statestore.getAll(id)
- if (state) {
- // merge the statestore with our props to account for dynamically set properties:
- node.ui.pages.set(id, { ...page, ...state })
- }
- }
-
- // loop over groups - check statestore if we've had any dynamic properties set
- for (const [id, group] of node.ui.groups) {
- const state = statestore.getAll(id)
- if (state) {
- // merge the statestore with our props to account for dynamically set properties:
- node.ui.groups.set(id, { ...group, ...state })
- }
- }
-
- // pass the connected UI the UI config
- socket.emit('ui-config', node.id, {
- dashboards: Object.fromEntries(node.ui.dashboards),
- heads: Object.fromEntries(node.ui.heads),
- pages: Object.fromEntries(node.ui.pages),
- themes: Object.fromEntries(node.ui.themes),
- groups: Object.fromEntries(node.ui.groups),
- widgets: Object.fromEntries(node.ui.widgets)
- })
- }
-
- // remove event handler socket listeners for a given socket connection
- function cleanupEventHandlers (socket) {
- try {
- socket.removeAllListeners('widget-action')
- } catch (_error) { /* do nothing */ }
- try {
- socket.removeAllListeners('widget-change')
- } catch (_error) { /* do nothing */ }
- try {
- socket.removeAllListeners('widget-load')
- } catch (_error) { /* do nothing */ }
- try {
- socket.removeAllListeners('widget-send')
- } catch (_error) { /* do nothing */ }
- try {
- socket.removeAllListeners('disconnect')
- } catch (_error) { /* do nothing */ }
-
- // check if any widgets have defined custom socket events
- // remove their listeners to make sure we clean up properly
- node.ui?.widgets?.forEach((widget) => {
- if (widget.hooks?.onSocket) {
- for (const [eventName] of Object.entries(widget.hooks.onSocket)) {
- try {
- socket.removeAllListeners(eventName)
- } catch (_error) { /* do nothing */ }
- }
- }
- })
- }
-
- function setupEventHandlers (socket, onConnection) {
- socket.on('widget-send', onSend.bind(null, socket))
- socket.on('widget-action', onAction.bind(null, socket))
- socket.on('widget-change', onChange.bind(null, socket))
- socket.on('widget-load', onLoad.bind(null, socket))
-
- // check if any widgets have defined custom socket events
- // most common with third-party widgets that are not part of core Dashboard 2.0
- const registered = [] // track which widget types we've already subscribed for
- node.ui?.widgets?.forEach((widget) => {
- if (widget.hooks?.onSocket) {
- for (const [eventName, handler] of Object.entries(widget.hooks.onSocket)) {
- // we only need add the listener for a given event type the once
- if (eventName === 'connection') {
- if (onConnection) {
- // these handlers are setup as part of an onConnection event, so trigegr these now
- handler(socket)
- }
- } else {
- widget._onSocketHandlers = widget._onSocketHandlers || {}
- widget._onSocketHandlers[eventName] = handler.bind(null, socket)
- socket.on(eventName, widget._onSocketHandlers[eventName])
- }
- }
- registered.push(widget.type)
- }
- })
-
- // handle disconnection
- socket.on('disconnect', reason => {
- cleanupEventHandlers(socket)
- delete uiShared.connections[socket.id]
- node.log(`Disconnected ${socket.id} due to ${reason}`)
- })
- }
-
- /**
- * on connection handler for SocketIO
- * @param {Socket} socket socket.io socket connecting to the server
- */
- function onConnection (socket) {
- // record mapping from connection to he ui-base node
- socket._baseId = node.id
-
- // node.connections[socket.id] = socket // store the connection for later use
- uiShared.connections[socket.id] = socket // store the connection for later use
-
- emitConfig(socket)
-
- // clean up then re-register listeners
- // cleanupEventHandlers(socket)
- // setup connections, and fire any 'on('connection')' events
- setupEventHandlers(socket, true)
- }
- /**
- * Handles a widget-action event from the UI
- * @param {Socket} conn - socket.io socket connecting to the server
- * @param {String} id - widget id sending the action
- * @param {*} msg - The node-red msg object to forward
- * @returns void
- */
- async function onAction (conn, id, msg) {
- // Hooks API - onAction(conn, id, msg)
- RED.plugins.getByType('node-red-dashboard-2').forEach(plugin => {
- if (plugin.hooks?.onAction && msg) {
- msg = plugin.hooks.onAction(conn, id, msg)
- }
- })
-
- if (!msg) {
- // a plugin has made msg blank - meaning that we don't want to send it on
- return
- }
-
- msg = addConnectionCredentials(RED, msg, conn, n)
-
- // ensure msg is an object. Assume the incoming data is the payload if not
- if (!msg || typeof msg !== 'object') {
- msg = { payload: msg }
- }
-
- // get widget node and configuration
- const { wNode, widgetConfig, widgetEvents } = getWidgetAndConfig(id)
-
- // ensure we can get the requested widget from the runtime & that this widget has an onAction handler
- if (!wNode || !widgetEvents.onAction) {
- return // widget does not exist (e.g. deleted from NR and deployed BUT the ui page was not refreshed)
- }
-
- // Wrap execution in a try/catch to ensure we don't crash Node-RED
- try {
- msg = await appendTopic(RED, widgetConfig, wNode, msg)
-
- // pre-process the msg before send on the msg (if beforeSend is defined)
- if (widgetEvents?.beforeSend && typeof widgetEvents.beforeSend === 'function') {
- msg = await widgetEvents.beforeSend(msg)
- }
-
- // send the msg onwards
- wNode.send(msg)
- } catch (error) {
- let errorHandler = typeof (widgetEvents.onError) === 'function' ? widgetEvents.onError : null
- errorHandler = errorHandler || (typeof wNode.error === 'function' ? wNode.error : node.error)
- errorHandler && errorHandler(error)
- }
- }
-
- /**
- * Handles a widget-change event from the UI
- * @param {Socket} conn - socket.io socket connecting to the server
- * @param {String} id - widget id sending the action
- * @param {*} value - The value to send to node-red. Typically this is the payload
- * @returns void
- */
- async function onChange (conn, id, value) {
- // console.log('conn:' + conn.id, 'on:widget-change:' + id, value)
-
- // get widget node and configuration
- const { wNode, widgetConfig, widgetEvents } = getWidgetAndConfig(id)
-
- if (!wNode) {
- return // widget does not exist any more (e.g. deleted from NR and deployed BUT the ui page was not refreshed)
- }
- let msg = datastore.get(id) || {}
-
- RED.plugins.getByType('node-red-dashboard-2').forEach(plugin => {
- if (plugin.hooks?.onChange) {
- msg = plugin.hooks.onChange(conn, id, msg)
- }
- })
-
- if (!msg) {
- // a plugin has made msg blank - meaning that we don't want to send it on
- return
- }
-
- msg = addConnectionCredentials(RED, msg, conn, n)
-
- async function defaultHandler (msg, value) {
- if (typeof (value) === 'object' && value !== null && hasProperty(value, 'payload')) {
- msg.payload = value.payload
- } else {
- msg.payload = value
- }
-
- msg = await appendTopic(RED, widgetConfig, wNode, msg)
-
- if (widgetEvents?.beforeSend) {
- msg = await widgetEvents.beforeSend(msg)
- }
- datastore.save(n, wNode, msg)
- wNode.send(msg) // send the msg onwards
- }
-
- // wrap execution in a try/catch to ensure we don't crash Node-RED
- try {
- // Most of the time, we can just use this default handler,
- // but sometimes a node needs to do something specific (e.g. ui-switch)
- const handler = typeof (widgetEvents.onChange) === 'function' ? widgetEvents.onChange : defaultHandler
- await handler(msg, value)
- } catch (error) {
- console.log(error)
- let errorHandler = typeof (widgetEvents.onError) === 'function' ? widgetEvents.onError : null
- errorHandler = errorHandler || (typeof wNode.error === 'function' ? wNode.error : node.error)
- errorHandler && errorHandler(error)
- }
- }
-
- /**
- * Handles a widget-send event from the UI
- * This takes a msg input, and emits it from the relevant node (normally a template node)
- * also stores in the data store, and does not consider any previously stored messages (unlike widget-change)
- * @param {Socket} conn - socket.io socket connecting to the server
- * @param {String} id - widget id sending the action
- * @param {*} msg - The value to send to node-red. Typically this is the payload
- * @returns void
- */
- async function onSend (conn, id, msg) {
- // console.log('conn:' + conn.id, 'on:widget-send:' + id, msg)
-
- // get widget node and configuration
- const { wNode, widgetEvents } = getWidgetAndConfig(id)
-
- if (!wNode) {
- return // widget does not exist any more (e.g. deleted from NR and deployed BUT the ui page was not refreshed)
- }
-
- RED.plugins.getByType('node-red-dashboard-2').forEach(plugin => {
- if (plugin.hooks?.onSend) {
- msg = plugin.hooks.onSend(conn, id, msg)
- }
- })
-
- if (!msg) {
- // a plugin has made msg blank - meaning that we don't want to send it on
- return
- }
-
- msg = addConnectionCredentials(RED, msg, conn, n)
-
- async function defaultHandler (value) {
- if (widgetEvents?.beforeSend) {
- msg = await widgetEvents.beforeSend(msg)
- }
- datastore.save(n, wNode, msg)
- wNode.send(msg) // send the msg onwards
- }
-
- // wrap execution in a try/catch to ensure we don't crash Node-RED
- try {
- // Most of the time, we can just use this default handler,
- // but sometimes a node needs to do something specific (e.g. ui-switch)
- const handler = typeof (widgetEvents.onSend) === 'function' ? widgetEvents.onSend : defaultHandler
- await handler(msg)
- } catch (error) {
- console.log(error)
- let errorHandler = typeof (widgetEvents.onError) === 'function' ? widgetEvents.onError : null
- errorHandler = errorHandler || (typeof wNode.error === 'function' ? wNode.error : node.error)
- errorHandler && errorHandler(error)
- }
- }
-
- async function onLoad (conn, id, msg) {
- // console.log('conn:' + conn.id, 'on:widget-load:' + id, msg)
-
- if (!id) {
- console.error('No widget id provided for widget-load event')
- return
- }
-
- const { wNode, widgetEvents } = getWidgetAndConfig(id)
- // any widgets we hard-code into our front end (e.g ui-notification for connection alerts) will start with ui-
- // Node-RED built nodes will be a random UUID
- if (!wNode && !id.startsWith('ui-')) {
- console.log('widget does not exist any more')
- return // widget does not exist any more (e.g. deleted from NR and deployed BUT the ui page was not refreshed)
- }
- async function handler () {
- let msg = datastore.get(id)
- const state = statestore.getAll(id)
- RED.plugins.getByType('node-red-dashboard-2').forEach(plugin => {
- if (plugin.hooks?.onLoad) {
- msg = plugin.hooks.onLoad(conn, id, msg, state)
- }
- })
-
- if (!msg && !state) {
- // a plugin has made msg blank - meaning that we do anything else
- return
- }
-
- conn.emit('widget-load:' + id, msg, state)
- }
- // wrap execution in a try/catch to ensure we don't crash Node-RED
- try {
- handler()
- } catch (error) {
- let errorHandler = typeof (widgetEvents.onError) === 'function' ? widgetEvents.onError : null
- errorHandler = errorHandler || (typeof wNode.error === 'function' ? wNode.error : node.error)
- errorHandler && errorHandler(error)
- }
- }
-
- /**
- * Get the widget node and associated configuration/event hooks
- * @param {String} id - ID of the widget
- * @returns {Object} - { wNode, widgetConfig, widgetEvents, widget }
- */
- function getWidgetAndConfig (id) {
- // node.ui?.widgets is empty?
- // themes, groups, etc. are not empty?
- const wNode = RED.nodes.getNode(id)
- const widget = node.ui?.widgets?.get(id)
- const widgetConfig = widget?.props || {}
- const widgetEvents = widget?.hooks || {}
- return { wNode, widgetConfig, widgetEvents, widget }
- }
-
- // When a UI connects - send the UI Config from Node-RED to the UI
- uiShared.ioServer.on('connection', onConnection)
-
- // Make sure we clean up after ourselves
- node.on('close', (removed, done) => {
- uiShared.ioServer?.off('connection', onConnection)
- for (const conn of Object.values(uiShared.connections)) {
- cleanupEventHandlers(conn)
- }
- close(node, function (err) {
- if (err) {
- node.error(`Error closing socket.io server for ${node.id}`, err)
- }
- done()
- })
- })
-
- /**
- * External Functions for managing UI Components
- */
- // store ui config to be sent to UI
- node.ui = {
- heads: new Map(),
- dashboards: new Map(),
- pages: new Map(),
- themes: new Map(),
- groups: new Map(),
- widgets: new Map()
- }
-
- node.stores = {
- data: datastore,
- state: statestore
- }
-
- /**
- * Queue up a config emit to the UI. This is a debounced function
- * NOTES:
- * * only sockets connected to this node will receive the config
- * * each ui-node will have it's own connections and will emit it's own config
- * @returns {void}
- */
- node.requestEmitConfig = function () {
- if (node.emitConfigRequested) {
- return
- }
- node.emitConfigRequested = setTimeout(() => {
- try {
- // emit config to all connected UI for this ui-base
- Object.values(uiShared.connections).forEach(socket => {
- emitConfig(socket)
- })
- } finally {
- node.emitConfigRequested = null
- }
- }, 300)
- }
-
- /**
- * Allow for any child node to emit to all connected UIs
- */
- node.emit = emit
-
- node.getBaseURL = function () {
- // get the endpoint for the ui-base
- const path = n.path || ''
- // get our HTTP root, defined by NR Settings
- const base = RED.settings.httpNodeRoot || '/'
- const basePart = base.endsWith('/') ? base : `${base}/`
- const dashPart = path.startsWith('/') ? path.slice(1) : path
- const fullPath = `${basePart}${dashPart}`
- return fullPath
- }
-
- node.registerTheme = function (theme) {
- const { _wireCount, _inputCallback, _inputCallbacks, _closeCallbacks, wires, type, ...t } = theme
- node.ui.themes.set(t.id, t)
- }
-
- /**
- * Register allows for pages, widgets, groups, etc. to register themselves with the Base UI Node
- * @param {*} page
- * @param {*} widget
- */
- node.register = function (page, group, widgetNode, widgetConfig, widgetEvents) {
- // console.log('dashboard 2.0, UIBaseNode: node.register(...)', page, group, widgetNode, widgetConfig, widgetEvents)
- /**
- * Build UI Config
- */
-
- // strip widgetConfig of stuff we don't really care about (e.g. Node-RED x/y coordinates)
- // and leave us just with the properties set inside the Node-RED Editor, store as "props"
- // store our UI state properties under the .state key too
-
- let widget = null
-
- if (widgetNode && widgetConfig) {
- // default states
- if (statestore.getProperty(widgetConfig.id, 'enabled') === undefined) {
- statestore.set(n, widgetConfig, null, 'enabled', true)
- }
- if (statestore.getProperty(widgetConfig.id, 'visible') === undefined) {
- statestore.set(n, widgetConfig, null, 'visible', true)
- }
- if (statestore.getProperty(widgetConfig.id, 'class') === undefined) {
- statestore.set(n, widgetConfig, null, 'class', '')
- }
-
- // build widget object
- widget = {
- id: widgetConfig.id,
- type: widgetConfig.type,
- props: widgetConfig,
- layout: {
- width: widgetConfig.width || 3,
- height: widgetConfig.height || 1,
- order: widgetConfig.order || 0
- },
- state: statestore.getAll(widgetConfig.id),
- hooks: widgetEvents,
- src: uiShared.contribs[widgetConfig.type]
- }
- const parent = RED.nodes.getNode(widgetConfig.z)
- if (parent && parent.TYPE === 'subflow') {
- const orderEnv = parent.subflowInstance.env?.find(e => e.key === 'DB2_SF_ORDER')
- let order = parseInt(orderEnv?.value)
- if (isNaN(order)) {
- order = 0
- }
- widget.props.subflow = {
- id: widgetConfig.z,
- name: parent.subflowInstance?.name || parent.subflowDef.name,
- order
- }
- }
-
- delete widget.props.id
- delete widget.props.type
- delete widget.props.x
- delete widget.props.y
- delete widget.props.wires
-
- if (widget.props.width === '0') {
- widget.props.width = null
- }
- if (widget.props.height === '0') {
- widget.props.height = null
- }
-
- // merge the statestore with our props toa ccount for dynamically set properties:
-
- // loop over props and check if we have any function definitions (e.g. onMounted, onInput)
- // and stringify them for transport over SocketIO
- for (const [key, value] of Object.entries(widget.props)) {
- // supported functions
- const supported = ['onMounted', 'onInput']
- if (supported.includes(key) && typeof value === 'function') {
- widget.props[key] = value.toString()
- } else if (key === 'methods') {
- for (const [method, fcn] of Object.entries(widget.props.methods)) {
- if (typeof fcn === 'function') {
- widget.props.methods[method] = fcn.toString()
- }
- }
- }
- }
- }
-
- // map dashboards by their ID
- if (!node.ui.dashboards.has(n.id)) {
- node.ui.dashboards.set(n.id, n)
- }
-
- // map themes by their ID
- if (page && page.type === 'ui-page' && !node.ui.themes.has(page.theme)) {
- const theme = RED.nodes.getNode(page.theme)
- if (theme) {
- node.registerTheme(theme)
- } else {
- node.warn(`Theme '${page.theme}' specified in page '${page.id}' does not exist`)
- }
- }
-
- // map pages by their ID
- if (page) {
- // ensure we have the latest instance of the page's node
- const { _users, ...p } = page
- node.ui.pages.set(page.id, p)
- }
-
- // map groups on a page-by-page basis
- if (group) {
- const { _user, type, ...g } = group
- node.ui.groups.set(group.id, g)
- }
-
- // map widgets on a group-by-group basis
- if (widgetNode && widgetConfig && !node.ui.widgets.has(widget.id)) {
- node.ui.widgets.set(widget.id, widget)
- }
-
- /**
- * Helper Function for testing
- */
-
- if (widgetNode) {
- widgetNode.getState = function () {
- return datastore.get(widgetNode.id)
- }
-
- /**
- * Event Handlers
- */
-
- // add Node-RED listener to the widget for when it's corresponding node receives a msg in Node-RED
- widgetNode?.on('input', async function (msg, send, done) {
- // clean msg - #668
- delete msg.res
- delete msg.req
-
- // ensure we have latest instance of the widget's node
- const wNode = RED.nodes.getNode(widgetNode.id)
- if (!wNode) {
- return // widget does not exist any more (e.g. deleted from NR and deployed BUT the ui page was not refreshed)
- }
-
- // Hooks API - onInput(msg)
- RED.plugins.getByType('node-red-dashboard-2').forEach(plugin => {
- if (plugin.hooks?.onInput) {
- msg = plugin.hooks.onInput(msg)
- }
- })
-
- if (!msg) {
- // a plugin has made msg blank - meaning that we do anything else
- return
- }
-
- try {
- // pre-process the msg before running our onInput function
- if (widgetEvents?.beforeSend) {
- msg = await widgetEvents.beforeSend(msg)
- }
-
- // standard dynamic property handlers
- if (hasProperty(msg, 'enabled')) {
- statestore.set(n, widgetNode, msg, 'enabled', msg.enabled)
- }
- if (hasProperty(msg, 'visible')) {
- statestore.set(n, widgetNode, msg, 'visible', msg.visible)
- }
- if (hasProperty(msg, 'class') || (hasProperty(msg, 'ui_update') && hasProperty(msg.ui_update, 'class'))) {
- const cls = msg.class || msg.ui_update?.class
- statestore.set(n, widgetNode, msg, 'class', cls)
- }
-
- // run any node-specific handler defined in the Widget's component
- if (widgetEvents?.onInput) {
- await widgetEvents?.onInput(msg, send)
- } else {
- // msg could be null if the beforeSend errors and returns null
- if (msg) {
- // store the latest msg passed to node
- datastore.save(n, widgetNode, msg)
-
- if (widgetConfig.topic || widgetConfig.topicType) {
- msg = await appendTopic(RED, widgetConfig, wNode, msg)
- }
- if (hasProperty(widgetConfig, 'passthru')) {
- if (widgetConfig.passthru) {
- send(msg)
- }
- } else {
- send(msg)
- }
- }
- }
-
- // emit to all connected UIs
- emit('msg-input:' + widget.id, msg, wNode)
-
- done()
- } catch (err) {
- if (err.type === 'warn') {
- wNode.warn(err.message)
- done()
- } else {
- done(err)
- }
- }
- })
-
- // when a widget is "closed" remove it from this Base Node's knowledge
- widgetNode?.on('close', function (removed, done) {
- if (removed) {
- // widget has been removed from the Editor
- // clear any data from datastore
- datastore.clear(widgetNode.id)
- }
- node.deregister(null, null, widgetNode)
- done()
- })
- }
- node.requestEmitConfig() // queue up a config emit to the UI
- }
-
- node.deregister = function (page, group, widgetNode) {
- let changes = false
- // remove widget from our UI config
- if (widgetNode) {
- const widget = node.ui.widgets.get(widgetNode.id)
- if (widget.hooks?.onSocket) {
- // We have some custom socketIO hooks to remove
-
- // loop over SocketIO connections
- for (const socket of Object.values(uiShared.connections)) {
- // loop over events
- for (const [eventName] of Object.entries(widget.hooks.onSocket)) {
- // remove the listener for this event
- if (widget._onSocketHandlers) {
- socket.off(eventName, widget._onSocketHandlers[eventName])
- }
- }
- }
- }
- node.ui.widgets.delete(widgetNode.id)
- changes = true
- }
-
- // if there are no more widgets on this group, remove the group from our UI config
- if (group && [...node.ui.widgets].filter(w => w.props?.group === group.id).length === 0) {
- node.ui.groups.delete(group.id)
- changes = true
- }
-
- // if there are no more groups on this page, remove the page from our UI config
- if (page && [...node.ui.groups].filter(g => g.page === page.id).length === 0) {
- node.ui.pages.delete(page.id)
- changes = true
- }
- if (changes) {
- node.requestEmitConfig()
- }
- }
-
- // Finally, queue up a config emit to the UI.
- // NOTE: this is a cautionary measure only - typically the registration of nodes will queue up a config emit
- // but in cases where the dashboard has no widgets registered, we still need to emit a config
- node.requestEmitConfig()
- }
-
- RED.nodes.registerType('ui-base', UIBaseNode)
- }
|