UNPKG

node-red-contrib-uibuilder

Version:

Easily create data-driven web UI's for Node-RED. Single- & Multi-page. Multiple UI's. Work with existing web development workflows or mix and match with no-code/low-code features.

680 lines (589 loc) 33.3 kB
/* eslint-disable block-scoped-var */ /** * Copyright (c) 2017-2024 Julian Knight (Totally Information) * https://it.knightnet.org.uk, https://github.com/TotallyInformation/node-red-contrib-uibuilder * * Licensed under the Apache License, Version 2.0 (the 'License'); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an 'AS IS' BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. **/ 'use strict' /** --- Type Defs --- * @typedef {import('../../typedefs.js').MsgAuth} MsgAuth * @typedef {import('../../typedefs.js').uibNode} uibNode * @typedef {import('../../typedefs.js').uibConfig} uibConfig * @typedef {import('../../typedefs.js').runtimeRED} runtimeRED * @typedef {import('../../typedefs.js').runtimeNodeConfig} runtimeNodeConfig * @typedef {import('../../typedefs.js').uibuilderEditorVars} uibuilderEditorVars */ //#region ------ Require packages ------ // // uibuilder custom const uiblib = require('../libs/uiblib.js') // Utility library for uibuilder const tilib = require('../libs/tilib.js') // General purpose library (by Totally Information) const packageMgt = require('../libs/package-mgt.js') const fslib = require('../libs/fs.js') // File/folder handling library (by Totally Information) // Wrap these require's with try/catch to force better error reports - just in case any of the modules have issues try { // templateConf // Template configuration metadata var templateConf = require('../../templates/template_dependencies.js') // eslint-disable-line no-var } catch (e) { console.error('[uibuilder] REQUIRE TEMPLATE-CONF failed::', e) } try { // sockets // Singleton, only 1 instance of this class will ever exist. So it can be used in other modules within Node-RED. var sockets = require('../libs/socket.js') // eslint-disable-line no-var } catch (e) { console.error('[uibuilder] REQUIRE SOCKET failed::', e) } try { // web // Singleton, only 1 instance of this class will ever exist. So it can be used in other modules within Node-RED. var web = require('../libs/web.js') // eslint-disable-line no-var } catch (e) { console.error('[uibuilder] REQUIRE WEB failed::', e) } // try { // var security = require('./libs/sec-lib') // Singleton, only 1 instance of this class will ever exist. So it can be used in other modules within Node-RED. // } catch (e) { // console.error('[uibuilder] REQUIRE SECURITY failed::', e) // } // Core node.js const path = require('path') //#endregion ----- Require packages ----- // //#region ------ uibuilder module-level globals ------ // /** @type {uibConfig} */ const uib = { me: fslib.readJSONSync(path.join( __dirname, '..', '..', 'package.json' )), moduleName: 'uibuilder', nodeRoot: '', deployments: {}, instances: {}, apps: {}, masterTemplateFolder: path.join( __dirname, '..', '..', 'templates' ), masterStaticFeFolder: path.join( __dirname, '..', '..', 'front-end' ), rootFolder: null, configFolder: null, configFolderName: '.config', commonFolder: null, commonFolderName: 'common', sioUseMwName: 'sioUse.js', sioMsgOutMwName: 'sioMsgOut.js', ioChannels: { control: 'uiBuilderControl', client: 'uiBuilderClient', server: 'uiBuilder' }, nodeVersion: process.version.replace('v', '').split('.'), staticOpts: {}, // { maxAge: 31536000, immutable: true, }, deleteOnDelete: {}, customServer: { // set correctly in libs/web.js:_webSetup() port: undefined, type: 'http', host: undefined, hostName: undefined, isCustom: false, // These will only be applied if using a custom ExpressJS server serverOptions: { // @since v7 - make Express URL's case-sensitive to match w3c guidelines and socket.io 'case sensitive routing': true, // For security 'x-powered-by': false, }, }, reDeployNeeded: '4.1.2', degitEmitter: undefined, RED: null, instanceApiAllowed: false, } /** Current module version (taken from package.json) @constant {string} uib.version */ uib.version = uib.me.version /** Dummy logging * @type {Object<string, Function>} */ const dummyLog = { fatal: function() {}, // fatal - only those errors which make the application unusable should be recorded error: function() {}, // error - record errors which are deemed fatal for a particular request + fatal errors warn: function() {}, // warn - record problems which are non fatal + errors + fatal errors info: function() {}, // info - record information about the general running of the application + warn + error + fatal errors debug: function() {}, // debug - record information which is more verbose than info + info + warn + error + fatal errors trace: function() {}, // trace - record very detailed logging + debug + info + warn + error + fatal errors } let log = dummyLog // reset to RED.log or anything else you fancy at any point // Placeholder - set in export let userDir = '' //#endregion ----- uibuilder module-level globals ----- // //#region ------ module-level functions ------ // /** Create external event listeners * Called for every uibuilder node instance * @param {uibNode} node Reference to node instance */ function externalEvents(node) { const RED = uib.RED // The event name to listen out for const eventName = `UIBUILDER/send/${node.url}` node.sender = (msg) => { // this.send(msg) sockets.sendToFe(msg, node, uib.ioChannels.server) } // Create new listener for the given topic, they are removed on close RED.events.on(eventName, node.sender) } //#endregion ----- End of mod-level fns ----- // /** 1) The function that defines the node * @param {runtimeRED} RED Node-RED's runtime object */ function Uib(RED) { uib.RED = RED runtimeSetup() // (1a) /** 2) Register the node by name. This must be called before overriding any of the * Node functions. */ RED.nodes.registerType(uib.moduleName, nodeInstance, { credentials: { jwtSecret: { type: 'password' }, }, // Makes these available to the editor as RED.settings.uibuilderxxxxxx settings: { // The server's NODE_ENV environment var (e.g. PRODUCTION or DEVELOPMENT) uibuilderNodeEnv: { value: process.env.NODE_ENV, exportable: true }, // Available templates and details uibuilderTemplates: { value: templateConf, exportable: true }, // Custom server details uibuilderCustomServer: { value: (uib.customServer), exportable: true }, // Current version of uibuilder uibuilderCurrentVersion: { value: (uib.version), exportable: true }, // Should the editor tell the user that a redeploy is needed (based on uib versions) uibuilderRedeployNeeded: { value: uib.reDeployNeeded, exportable: true }, // TODO REMOVE? since only correct at first load and an API is needed anyway. List of the deployed uib instances [{node_id: url}] uibuilderInstances: { value: uib.instances, exportable: true }, // uibRoot uibuilderRootFolder: { value: uib.rootFolder, exportable: true }, }, }) } // ==== End of Uib ==== // /** 1a) All of the initialisation of the Node * This is only run once no matter how many uib node instances are added to a flow */ function runtimeSetup() { // eslint-disable-line sonarjs/cognitive-complexity if ( uib.RED === null ) return const RED = uib.RED // Add list all uibuilder apps function to RED.util so it can be used inside function nodes // NOTE: Only add things here that require uibuilder configuration data which won't be available // until after plugins are defined. Most things should be added in the the runtime plugin. RED.util.uib = { /** Return a list of all instances * @returns {object} List of all registered uibuilder instances */ listAllApps: () => { return uib.apps }, /** Send a message to a specific uibuilder instance * @param {string} uibName The name (url) of the uibuilder instance to send via * @param {object} msg Message object to send to the front-end */ send: (uibName, msg) => { const targetNode = RED.nodes.getNode(uib.apps[uibName].node) if ( !targetNode ) { throw new Error(`[RED.util.uib.send] ERROR: uibuilder instance '${uibName}' not found`) } msg.from = 'server/function-node' sockets.sendToFe2(msg, targetNode) }, // Merge in functions from the runtime plugin ...RED.util.uib } //#region ----- back-end debugging ----- // log = RED.log log.trace('🌐[uibuilder:runtimeSetup] ----------------- uibuilder - module started -----------------') //#endregion ----- back-end debugging ----- // // When uibuilder enters runtime state, show the details in the log let initialised = false RED.events.on('runtime-event', function(event) { if (event.id === 'runtime-state' && initialised === false ) { initialised = true const myroot = uib.nodeRoot === '' ? '/' : uib.nodeRoot RED.log.info('+-----------------------------------------------------') RED.log.info(`| ${uib.moduleName} v${uib.version} initialised 🌐`) RED.log.info(`| root folder: ${uib.rootFolder}`) if ( uib.customServer.isCustom === true ) { RED.log.info('| Using custom ExpressJS webserver at:') RED.log.info(`| ${uib.customServer.type}://${uib.customServer.host}:${uib.customServer.port}${uib.nodeRoot} or ${uib.customServer.type}://localhost:${uib.customServer.port}${myroot}`) } else { RED.log.info('| Using Node-RED\'s webserver at:') RED.log.info(`| ${RED.settings.https ? 'https' : 'http'}://${RED.settings.uiHost}:${RED.settings.uiPort}${myroot}`) } RED.log.info('| Installed packages:') // @ts-ignore const pkgs = Object.keys(packageMgt.uibPackageJson.uibuilder.packages) for (let i = 0; i < pkgs.length; i += 4) { const k = [] for (let j = 0; j <= 3; j++) { if ( pkgs[i + j] ) k.push(pkgs[i + j]) } RED.log.info(`| ${k.join(', ')}`) } RED.log.info('+-----------------------------------------------------') } }) //#region ----- Constants for standard setup ----- // /** Folder containing settings.js, installed nodes, etc. @constant {string} userDir */ userDir = RED.settings.userDir uib.rootFolder = path.join(userDir, uib.moduleName) // If projects are enabled - update root folder to `<userDir>/projects/<projectName>/uibuilder/<url>` if ( uiblib.getProps(RED, RED.settings.get('editorTheme'), 'projects.enabled') === true ) { const currProject = uiblib.getProps(RED, RED.settings.get('projects'), 'activeProject', '') if ( currProject !== '' ) uib.rootFolder = path.join(userDir, 'projects', currProject, uib.moduleName) } // Record the httpNodeRoot for later use uib.nodeRoot = RED.settings.httpNodeRoot // Get and record uibuilder settings from settings.js into the `uib` master object - these apply to all instances of uib if ( RED.settings.uibuilder ) { const settings = RED.settings.uibuilder // Change the root folder if ( settings.uibRoot && typeof settings.uibRoot === 'string') { uib.rootFolder = settings.uibRoot } // Get web-relavent uibuilder settings from settings.js uib.customServer.port = Number(RED.settings.uiPort) // Note the system host name uib.customServer.hostName = require('os').hostname() /** HTTP(s) port. If set & different to node-red, uibuilder will use its own ExpressJS server */ // @ts-ignore - deliberately allowing string/number comparison if ( settings.port && settings.port != RED.settings.uiPort) { // eslint-disable-line eqeqeq uib.customServer.isCustom = true uib.customServer.port = Number(settings.port) // Override the httpNodeRoot setting, has to be empty string. Use reverse proxy to change instead if needed. uib.nodeRoot = '' } // http, https or http2 (default=http) if ( RED.settings.https ) uib.customServer.type = 'https' if ( settings.customType ) uib.customServer.type = settings.customType // Allow instance-level api's to be loaded (default=false) if ( settings.instanceApiAllowed === true ) uib.instanceApiAllowed = true if ( settings.serverOptions ) { uib.customServer.serverOptions = Object.assign(uib.customServer.serverOptions, settings.serverOptions) } } // --- end of settings.js --- // /** Locations for uib config can common folders */ uib.configFolder = path.join(uib.rootFolder, uib.configFolderName) uib.commonFolder = path.join(uib.rootFolder, uib.commonFolderName) //#endregion -------- Constants -------- // // (a) Configure the UibFs handler class fslib.setup(uib) //#region ----- Set up uibuilder root, root/.config & root/common folders ----- // /** Check uib root folder: create if needed, writable? */ let uibRootFolderOK = true // Try to create root and root/.config - ignore error if it already exists try { fslib.ensureDirSync(uib.configFolder) // creates both folders log.trace(`🌐[uibuilder[:runtimeSetup] uibRoot folder exists. ${uib.rootFolder}` ) } catch (e) { if ( e.code !== 'EEXIST' ) { // ignore folder exists error RED.log.error(`🌐🛑[uibuilder:runtimeSetup] Custom folder ERROR, path: ${uib.rootFolder}. ${e.message}`) uibRootFolderOK = false } } // Try to access the root folder (read/write) - if we can, create and serve the common resource folder try { fslib.accessSync( uib.rootFolder, 'rw' ) // try to access read/write log.trace(`🌐[uibuilder[:runtimeSetup] uibRoot folder is read/write accessible. ${uib.rootFolder}` ) } catch (e) { RED.log.error(`🌐🛑[uibuilder:runtimeSetup] Root folder is not accessible, path: ${uib.rootFolder}. ${e.message}`) uibRootFolderOK = false } // Assuming all OK, copy over the master .config folder without overwriting (vendor package list, middleware) if (uibRootFolderOK === true) { // We want to always overwrite the .config template files const fsOpts = { 'overwrite': true, 'preserveTimestamps': true, 'recursive': true, } try { fslib.copySync( path.join( uib.masterTemplateFolder, uib.configFolderName ), uib.configFolder, fsOpts) log.trace(`🌐[uibuilder:runtimeSetup] Copied template .config folder to local .config folder ${uib.configFolder} (not overwriting)` ) } catch (e) { RED.log.error(`🌐🛑[uibuilder:runtimeSetup] Master .config folder copy ERROR, path: ${uib.masterTemplateFolder}. ${e.message}`) uibRootFolderOK = false } // and copy the common folder from template (contains the default blue node-red icon) fsOpts.overwrite = false // we don't want to overwrite any common folder files try { fslib.copyCb( path.join( uib.masterTemplateFolder, uib.commonFolderName )+'/', uib.commonFolder+'/', fsOpts, function(err) { if (err) { log.error(`🌐🛑[uibuilder:runtimeSetup] Error copying common template folder from ${path.join( uib.masterTemplateFolder, uib.commonFolderName)} to ${uib.commonFolder}. ${err.message}`, err) } else { log.trace(`🌐[uibuilder:runtimeSetup] Copied common template folder to local common folder ${uib.commonFolder} (not overwriting)` ) } }) } catch (e) { // should never happen log.error(`🌐🛑[uibuilder:runtimeSetup] COPY OF COMMON FOLDER FAILED. ${e.message}`) } // It is served up at the instance level to allow caching to be configured. It is used as a static resource folder (added in nodeInstance() so available for each instance as `./common/`) } // If the root folder setup failed, throw an error and give up completely if (uibRootFolderOK !== true) { throw new Error(`[uibuilder:runtimeSetup] Failed to set up uibuilder root folder structure correctly. Check log for additional error messages. Root folder: ${uib.rootFolder}.`) } //#endregion ----- root folder ----- // /** (b) Do this before doing the web setup so that the packages can be served but after the folder/file setup */ packageMgt.setup(uib) /** (c) We need an ExpressJS web server to serve the page and vendor packages. * since v2.0.0 2019-02-23 Moved from instance level (nodeInstance()) to module level * since v3.3.0 2021-03-16 Allow independent ExpressJS server/app */ web.setup(uib) /** (d) Pass core objects to the Socket.IO handler module */ sockets.setup(uib, web.server) RED.events.emit('uibuilder/runtimeSetupComplete', uib) } // --- end of runtimeSetup --- // /** 2) All of the initialisation of the Node Instance * This is callled once for each uibuilder node instance added to a flow * THIS IS ONLY RUN IF A NODE HAS BEEN OR IS BEING DEPLOYED. * type {function(this:runtimeNode&uib, runtimeNodeConfig & uib):void} * @param {runtimeNodeConfig & uibuilderEditorVars} config The configuration object passed from the Admin interface (see the matching HTML file) * @this {uibNode} */ function nodeInstance(config) { if ( uib.RED === null ) return const RED = uib.RED // If someone deploys but ignored the error about blank URL's - don't set up the node. if (!config.url || config.okToGo === false ) { RED.log.error(`🌐🛑[uibuilder] uibuilder node ${config.id} deployed with invalid URL in flow ${config.z} - not configuring`) return } /** Create the node instance - `this` can only be referenced AFTER here * @param {uibNode} this _ */ RED.nodes.createNode(this, config) //#region ====== Create local copies of the node configuration (as defined in the .html file) ====== // // NB: this.id and this.type are also available this.name = config.name ?? '' this.topic = config.topic ?? '' this.url = config.url // Undefined or '' is not valid this.oldUrl = config.oldUrl this.fwdInMessages = config.fwdInMessages ?? false this.allowScripts = config.allowScripts ?? false this.allowStyles = config.allowStyles ?? false this.copyIndex = config.copyIndex ?? true // DEPRECATED this.templateFolder = config.templateFolder ?? templateConf.blank.folder this.extTemplate = config.extTemplate this.showfolder = config.showfolder ?? false this.reload = config.reload ?? false this.sourceFolder = config.sourceFolder // NB: Do not add a default here as undefined triggers a check for index.html in web.js:setupInstanceStatic this.deployedVersion = config.deployedVersion this.showMsgUib = config.showMsgUib // Show additional client id in standard msgs (see socket.js) this.title = config.title ?? '' this.descr = config.descr ?? '' this.editurl = config.editurl ?? '' //#endregion ====== Local node config copy ====== // log.trace(`🌐[uibuilder[:nodeInstance:${this.url}] ================ instance registered ================`) log.trace(`🌐[uibuilder[:nodeInstance:${this.url}] node keys: ${JSON.stringify(Object.keys(this))}`) log.trace(`🌐[uibuilder[:nodeInstance:${this.url}] config keys: ${JSON.stringify(Object.keys(config))}`) log.trace(`🌐[uibuilder[:nodeInstance:${this.url}] Deployed Version: ${this.deployedVersion}`) if ( !this.url || typeof this.url !== 'string' || this.url.length < 1 ) { log.error('🌐🛑[uibuilder:nodeInstance] No valid URL provided. Cannot set up this uibuilder instance') this.statusDisplay = { fill: 'red', shape: 'dot', text: 'ERROR:NOT CONFIGURED - No URL' } uiblib.setNodeStatus( this ) return } this.statusDisplay = { fill: 'blue', shape: 'dot', text: 'Configuring node' } // if ( this.useSecurity === true ) this.statusDisplay.fill = 'yellow' // if ( this.allowUnauth === true ) this.statusDisplay.shape = 'ring' uiblib.setNodeStatus( this ) //#region ====== Instance logging/audit ====== // log.trace(`🌐[uibuilder[:nodeInstance:${this.url}] Node instance settings: ${JSON.stringify({ 'name': this.name, 'topic': this.topic, 'url': this.url, 'copyIndex': this.copyIndex, 'fwdIn': this.fwdInMessages, 'allowScripts': this.allowScripts, 'allowStyles': this.allowStyles, 'showfolder': this.showfolder })}`) // Keep a log of the active uib.instances @since 2019-02-02 uib.instances[this.id] = this.url log.trace(`🌐[uibuilder[:nodeInstance:${this.url}] Node uib.Instances Registered: ${JSON.stringify(uib.instances)}`) uib.apps[this.url] = { node: this.id, url: this.url, title: this.title, descr: this.descr, } // Keep track of the number of times each instance is deployed. // The initial deployment = 1 if ( Object.prototype.hasOwnProperty.call(uib.deployments, this.id) ) uib.deployments[this.id]++ else uib.deployments[this.id] = 1 log.trace(`🌐[uibuilder[:nodeInstance:${this.url}] Number of uib.Deployments: ${uib.deployments[this.id]}` ) // Track the number of messages received by this instance this.rcvMsgCount = 0 //#endregion ====== Instance logging/audit ====== // //#region ====== Local folder structure ====== // /** Name of the fs path used to hold custom files & folders for THIS INSTANCE of uibuilder * Files in this folder are also served to URL but take preference * over those in the nodes folders (which act as defaults) @type {string} */ this.customFolder = path.join(/** @type {string} */ (uib.rootFolder), this.url) // TODO Need to find a way to make this more robust for when folder rename fails // Check whether the url has been changed. If so, rename the folder if ( this.oldUrl !== undefined && this.oldUrl !== '' && this.url !== this.oldUrl ) { // rename (move) folder if possible - but don't overwrite try { fslib.moveSync(path.join(/** @type {string} */ (uib.rootFolder), this.oldUrl), this.customFolder, { overwrite: false }) log.trace(`🌐[uibuilder[:nodeInstance:${this.url}] Folder renamed from ${this.oldUrl} to ${this.url}`) // Notify other nodes RED.events.emit('uibuilder/URL-change', { oldURL: this.oldUrl, newURL: this.url, folder: this.customFolder } ) RED.events.emit(`uibuilder/URL-change/${this.oldUrl}`, { oldURL: this.oldUrl, newURL: this.url, folder: this.customFolder } ) } catch (e) { log.trace(`🌐[uibuilder[:nodeInstance:${this.url}] Could not rename folder. ${e.message}`) // Not worried if the source doesn't exist - this will regularly happen when changing the name BEFORE first deploy. if ( e.code !== 'ENOENT' ) { log.error(`🌐🛑[uibuilder:nodeInstance] RENAME OF INSTANCE FOLDER FAILED. Fatal. Manually change the URL back to the original. newUrl=${this.url}, oldUrl=${this.oldUrl}, Fldr=${this.customFolder}. Error=${e.message}`, e) } } // TODO Move this to a function in web.js // Remove the old router and remove from the routes list delete web.routers.instances[this.oldUrl] delete web.instanceRouters[this.oldUrl] // we continue to do the normal checks in case something failed or if this is an initial deploy (so no original folder exists) } // Does the custom folder exist? If not, create it and copy template to it. Otherwise make sure it is accessible. // TODO replace with fslib.ensureFolder() let customFoldersOK = true if ( !fslib.existsSync(this.customFolder) ) { // Does not exist so check whether built-in or external template wanted if ( this.templateFolder !== 'external' ) { // Internal template wanted - so copy it now const cpyOpts = { 'preserveTimestamps': true } const copyFrom = path.join( uib.masterTemplateFolder, this.templateFolder ) try { fslib.copySync( copyFrom, this.customFolder, cpyOpts) log.info(`🌐📘[uibuilder:nodeInstance:${this.url}] Created instance folder ${this.customFolder} and copied template files from ${copyFrom}` ) } catch (e) { log.error(`🌐🛑[uibuildernodeInstance] CREATE OF INSTANCE FOLDER '${this.customFolder}' & COPY OF TEMPLATE '${copyFrom}' FAILED. Fatal. Error=${e.message}`, e) customFoldersOK = false } } else { // External template wanted so try to load it fslib.replaceTemplate(this.url, this.templateFolder, this.extTemplate, 'startup-CopyTemplate', templateConf, uib, log) .then( () => { // resp => { // resp.statusMessage log.info(`🌐📘[uibuilder:nodeInstance:${this.url}] Created instance folder ${this.customFolder} and copied external template files from ${this.templateFolder}` ) return true }) .catch( err => { let statusMsg if ( err.code === 'MISSING_REF' ) { statusMsg = `Degit clone error. CHECK External Template Name. Name='${this.extTemplate}', url=${this.url}, cmd=startup-CopyTemplate. ${err.message}` } else { let mystr if ( this.templateFolder === 'external' ) mystr = `, ${this.extTemplate}` statusMsg = `Replace template error. ${err.message}. url=${this.url}. ${this.templateFolder}${mystr}` } log.error(`🌐🛑[uibuilder:nodeInstance:replaceTemplate] ${statusMsg}`, err) } ) } } else { try { fslib.accessSync(this.customFolder, 'w') } catch (e) { log.error(`🌐🛑[uibuilder:nodeInstance:${this.url}] Local custom folder ERROR`, e.message) customFoldersOK = false } } // We've checked that the custom folder is there and has the correct structure // TODO Add check for src folder? if ( customFoldersOK === true ) { // local custom folders are there ... log.trace(`🌐[uibuilder[:nodeInstance:${this.url}] Using local front-end folders in: ${this.customFolder}` ) } else { // Local custom folders are not right! log.error(`🌐🛑[uibuilder:nodeInstance:${this.url}] Wanted to use local front-end folders in ${this.customFolder} but could not`) } //#endregion ====== End of Local folder structure ====== // // If security turned on, set up security for this instance - NB: most sec processing done from socket.js // if ( this.useSecurity === true ) security.setupInstance(this) // Set up web services for this instance (static folders, middleware, etc) web.instanceSetup(this) /** Socket.IO instance configuration. Each deployed instance has it's own namespace */ sockets.addNS(this) // NB: Namespace is set from url // Save a reference to sendToFe to allow this and other nodes referencing this to send direct to clients this.sendToFe = sockets.sendToFe.bind(sockets) log.trace(`🌐[uibuilder[:nodeInstance:${this.url}] URL . . . . . : ${tilib.urlJoin( uib.nodeRoot, this.url )}`) log.trace(`🌐[uibuilder[:nodeInstance:${this.url}] Source files . : ${this.customFolder}`) // We only do the following if io is not already assigned (e.g. after a redeploy) this.statusDisplay.text = 'Node Initialised' uiblib.setNodeStatus( this ) // 3) Add event handler to process inbound messages // @ts-ignore this.on('input', inputMsgHandler) // 3rd-party node (non-flow) Event handlers (e.g. uib-sender) externalEvents(this) /** Do something when Node-RED is closing down which includes when this node instance is redeployed * Note use of arrow function so as to retain the correct `this` context */ this.on('close', (removed, done) => { log.trace(`🌐[uibuilder[:nodeInstance:close:${this.url}] nodeInstance:on-close: ${removed ? 'Node Removed' : 'Node (re)deployed'}`) // @ts-ignore this.removeListener('input', inputMsgHandler) // Cancel any event listeners for this node RED.events.off(`UIBUILDER/send/${this.url}`, this.sender) // Tidy up the ExpressJS routes if a node is removed if (removed) { delete web.routers.instances[this.url] delete web.instanceRouters[this.url] } // Do any complex close processing here if needed - MUST BE LAST uiblib.instanceClose(this, uib, sockets, web, done) // done() }) // TODO Move to web // Shows an instance details debug page RED.httpAdmin.get(`/uibuilder/instance/${this.url}`, (req, res) => { res.status(200).send( web.showInstanceDetails(req, this) ) }) RED.events.emit('uibuilder/instanceSetupComplete', this) RED.events.emit(`uibuilder/instanceSetupComplete/${this.url}`, this) } // ----- end of nodeInstance ----- // /** 3) Handler function for node flow input events (when a node instance receives a msg from the flow) * NOTE: `this` context is still the parent within the function. * Also, this function does NOT have access to RED * see https://nodered.org/blog/2019/09/20/node-done * @param {object} msg The msg object received. * @param {Function} send Per msg send function, node-red v1+ * @param {Function} done Per msg finish function, node-red v1+ * @returns {undefined|null} Not a lot * @this {uibNode} **/ function inputMsgHandler (msg, send, done) { // const RED = uib.RED log.trace(`🌐[uibuilder[:${this.url}] nodeInstance:nodeInputHandler - emit received msg - Namespace: ${this.url}`) // debug // If msg is null, nothing will be sent if ( msg !== null ) { // if msg isn't null and isn't an object // NOTE: This is paranoid and shouldn't be possible! if ( typeof msg !== 'object' ) { // Force msg to be an object with payload of original msg msg = { 'payload': msg } } // Add topic from node config if present and not present in msg if ( !(Object.prototype.hasOwnProperty.call(msg, 'topic')) || msg.topic === '' ) { if ( this.topic !== '' ) msg.topic = this.topic else msg.topic = uib.moduleName } } // Keep this fn small for readability so offload any further, more customised code to another fn this.rcvMsgCount++ log.trace(`🌐[uibuilder[:uiblib:inputHandler:${this.url}] msg received via FLOW. ${this.rcvMsgCount} messages received. ${JSON.stringify(msg)}`) // If the input msg is a uibuilder control msg, then drop it to prevent loops if ( Object.prototype.hasOwnProperty.call(msg, 'uibuilderCtrl') ) return null // If msg has _ui property - is it from the client? If so, remove it. if (msg._ui && msg._ui.from && msg._ui.from === 'client') delete msg._ui // setNodeStatus({fill: 'yellow', shape: 'dot', text: 'Message Received #' + this.rcvMsgCount}, node) // Remove script/style content if admin settings don't allow if ( this.allowScripts !== true && Object.prototype.hasOwnProperty.call(msg, 'script') ) delete msg.script if ( this.allowStyles !== true && Object.prototype.hasOwnProperty.call(msg, 'style') ) delete msg.style // pass the complete msg object to the uibuilder client if ( (!Object.prototype.hasOwnProperty.call(msg, 'topic')) && (this.topic !== '') ) msg.topic = this.topic sockets.sendToFe( msg, this, uib.ioChannels.server ) // Pass on to output port 1 if wanted if (this.fwdInMessages) { // Send on the input msg to output send(msg) done() log.trace(`🌐[uibuilder[:uiblib:inputHandler:${this.url}] msg passed downstream to next node. ${JSON.stringify(msg)}`) } // tilib.dumpMem('On Msg') } // ----- End of inputMsgHandler ----- // /** Export the function that defines the node */ module.exports = Uib // EOF