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.

417 lines (372 loc) 21.7 kB
/* eslint-disable jsdoc/valid-types */ /** * Copyright (c) 2024-2026 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').uibNode} uibNode * @typedef {import('../typedefs.js').runtimeRED} runtimeRED * @typedef {import('../typedefs.js').runtimeNodeConfig} runtimeNodeConfig * @typedef {import('../typedefs.js').uibConfig} uibConfig */ const path = require('node:path') const { saferSerialize, } = require('./libs/tilib.cjs') // Safer JSON.stringify with circular reference handling // const { parseTelemetryFile, } = require('./libs/uiblib.cjs') const fslib = require('./libs/fs.cjs') // File/folder handling library (by Totally Information) const packageMgt = require('./libs/package-mgt.cjs') const web = require('./libs/web.cjs') const sockets = require('./libs/socket.cjs') const { renderToHTML, } = require('../front-end/utils/json-viewer.cjs') // WARNING: Don't try to deconstruct this, if you do, some things fail because they lose the correct `this` binding const uiblib = require('./libs/uiblib.cjs') /** @type {uibConfig} The uibuilder global configuration object, used throughout all nodes and libraries. */ const uib = require('./libs/uibGlobalConfig.cjs') /** Set up the global uibuilder configuration * @param {runtimeRED} RED Node-RED's runtime object */ function setupUibGlobalConfig(RED) { // Save a reference to the RED object so we can access it in other functions uib.RED = RED // Record the httpNodeRoot for later use uib.nodeRoot = RED.settings.httpNodeRoot // Get and record standardised project data const projects = RED.settings.get('projects') uib.projData = { // flowFile: RED.settings.flowFile, // userDir: RED.settings.userDir, enabled: RED.settings.editorTheme?.projects?.enabled, activeProject: projects?.activeProject, workflow: RED.settings.editorTheme?.projects?.workflow, projects: projects?.projects, projects_clean1: projects?.projects?.clean1, } // console.log(`🌐[uibuilder:runtimeSetup:setupUibGlobalConfig]`, { projData: uib.projData, }) // Get and record uibuilder settings from settings.js into the `uib` master object - these apply to all instances of uibuilder & Markweb if ( RED.settings.uibuilder ) { const settings = RED.settings.uibuilder // Change the root folder - moved to setupUibFs() // 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) { uib.customServer.isCustom = true uib.customServer.port = Number(settings.port) // Override the httpNodeRoot setting, has to be empty string for custom server. 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 // Allow custom ExpressJS server options to be set in settings.js if ( settings.serverOptions ) { uib.customServer.serverOptions = Object.assign(uib.customServer.serverOptions, settings.serverOptions) } // Allow override of the default Content Security Policy (CSP) header for uibuilder ExpressJS routes. if ( settings.contentSecurityPolicy ) { uib.customServer.contentSecurityPolicy = settings.contentSecurityPolicy } // Allow override of the telemetry service if ( 'telemetryEnabled' in settings && typeof settings.telemetryEnabled === 'boolean' ) { uib.telemetryEnabled = settings.telemetryEnabled } } } /** Set up the uibuilder global filing system config, checking permissions and initialising the fs class */ function setupUibFs() { const RED = uib.RED // Save reference to the default uibuilder root folder location (may be overridden by settings.js or project root) uib.rootFolder = path.join(RED.settings.userDir, uib.moduleName) // Allow to be overridden by settings.js if ( RED.settings?.uibuilder?.uibRoot && typeof RED.settings.uibuilder.uibRoot === 'string') { uib.rootFolder = RED.settings.uibuilder.uibRoot } else { /** Projects are a PAIN! * - Settings are in 2 places: RED.settings?.editorTheme?.projects and RED.settings.get('projects') * - Removing and disabling projects DOES NOT remove the RED.settings.get('projects') object, it just sets projectsEnabled to false. * - The additional project settings are in .config.projects.json. * - Enabling projects does NOT create a default project - everything is left pointing at userDir until you create a project and set it active. */ // @ts-ignore Are Node-RED projects enabled? Only apply if no explicit uibRoot override was set in settings.js if ( uib.projData.enabled === true ) { // @ts-ignore If so, we need the root to be relative to the project folder const currProject = uib.projData.activeProject ?? '' if ( currProject !== '' ) { uib.rootFolder = path.join(RED.settings.userDir, 'projects', currProject, uib.moduleName) } } } /** Locations for uib config can common folders */ uib.configFolder = path.join(uib.rootFolder, uib.configFolderName) uib.commonFolder = path.join(uib.rootFolder, uib.commonFolderName) // Check that the Node-RED userDir folder is writable - completely error if not try { fslib.accessSync( RED.settings.userDir, 'rw' ) // try to access read/write RED.log.trace(`🌐[uibuilder:runtimeSetup] uibRoot folder is read/write accessible. ${RED.settings.userDir}`) } catch (e) { throw new Error(`🌐🛑[uibuilder:runtimeSetup] UIBUILDER cannot be configured,\n Node-RED userDir folder "${RED.settings.userDir}" is not writable.`, e) } // (a) Configure the UibFs handler class (requires uib.RED - now using the uibGlobalConfig module) fslib.setup(uib) // Cannot required uib in fs module as it creates a circular dependency // #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 RED.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 RED.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) RED.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) { RED.log.error(`🌐🛑[uibuilder:runtimeSetup] Error copying common template folder from ${path.join( uib.masterTemplateFolder, uib.commonFolderName)} to ${uib.commonFolder}. ${err.message}`, err) } else { RED.log.trace(`🌐[uibuilder:runtimeSetup] Copied common template folder to local common folder ${uib.commonFolder} (not overwriting)` ) } }) } catch (e) { // should never happen RED.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 ----- // } /** Called when the plugin is added to the editor - is passed the RED global object */ function onAdd() { if ( uib.RED === null ) return const RED = uib.RED // Add some uibuilder specific utility functions to RED.util so they can be used inside function nodes // ! NOTE: If updating these, also update the TypeScript defs in editor-common.js. RED.util.uib = { /** Recursive object deep find * @param {*} obj The object to be searched * @param {Function} matcher Function that, if returns true, will result in cb(obj) being called * @param {Function} cb Callback function that takes a single arg `obj` */ deepObjFind: (obj, matcher, cb) => { if (matcher(obj)) { cb(obj) } for (const key in obj) { if (typeof obj[key] === 'object') { RED.util.uib.deepObjFind(obj[key], matcher, cb) } } }, /** Format a number to a given locale and decimal places * @param {number} inp Input number * @param {number} dp Number of output decimal places required (default=1) * @param {string} int Locale to use for number format (default=en-GB) * @returns {string} Formatted number as a string */ dp: (inp, dp = 1, int = 'en-GB') => { return inp.toLocaleString(int, { minimumFractionDigits: dp, maximumFractionDigits: dp, }) }, /** Return a list of all instances * @returns {object} List of all registered uibuilder instances */ listAllApps: () => { return uib.apps }, /** Render a JavaScript value to an HTML string using the json-viewer component's pure renderer. * The returned HTML includes optional embedded styles and a structured tree representation. * @param {*} data The data to render (any JavaScript value) * @param {object} [opts] Rendering options * @param {number} [opts.maxDepth] Maximum auto-expand depth. Default=2 * @param {boolean} [opts.collapsed] Whether all nodes start collapsed. Default=false * @param {boolean} [opts.editable] Whether scalar leaf values are editable. Default=false * @param {boolean} [opts.interactive] Whether to include search/collapse controls. Default=false * @param {boolean} [opts.includeStyles] Whether to embed the component CSS. Default=true * @returns {string} An HTML string representing the data tree */ renderToHTML: (data, opts = {}) => renderToHTML(data, opts), /** 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) }, /** Safer JSON.stringify with circular reference handling * @param {*} obj The object to serialize * @param {number} [space] Number of spaces for indentation (default=2) * @returns {string} JSON string */ saferSerialize: saferSerialize, /** Returns true/false or a default value for truthy/falsy and other values * @param {string|number|boolean|*} val The value to test * @param {any} deflt Default value to use if the value is not truthy/falsy * @returns {boolean|any} The truth! Or the default */ truthy: (val, deflt) => { let ret if (['on', 'On', 'ON', 'true', 'True', 'TRUE', '1', true, 1].includes(val)) ret = true else if (['off', 'Off', 'OFF', 'false', 'False', 'FALSE', '0', false, 0].includes(val)) ret = false else ret = deflt return ret }, // In case another plugin defines more of these and is processed first ...RED.util.uib, } } /** 1) The function that defines the plugin - we also set up the uibuilder global config and root folder here. * @param {runtimeRED} RED Node-RED's runtime object */ function pluginDefinition(RED) { RED.log.trace('🌐[uibuilder:runtimeSetup] ----------------- global config started -----------------') // RED.events.on('runtime-event', function(event) { // console.log(`🌐[uibuilder:runtimeSetup:runtime-event] ID: ${event.id}`) // }) setupUibGlobalConfig(RED) /** (a) Filing system checks and library setup */ setupUibFs() /** (b) Do this before doing the web setup so that the packages can be served but after the folder/file setup */ packageMgt.setup() /** (c) We need an ExpressJS web server to serve the page, socket.io and vendor packages. */ web.setup() /** (d) Pass web server reference to the Socket.IO handler module */ sockets.setup(web.server) // For a custom web server only: Catch SIGINT and close the server and any active connections if ( uib.customServer.isCustom === true ) { process.on('SIGINT', () => { RED.log.info(`🌐[uibuilder:runtimeSetup] Caught SIGINT, shutting down custom server and socket.io gracefully...`) sockets.io.sockets.sockets.forEach((socket) => { socket.disconnect(true) }) if ( web.connections.size > 0 ) { RED.log.info(`🌐[uibuilder:runtimeSetup] Force-closing active connections: ${web.connections.size}`) for (const socket of web.connections) { socket.destroy() web.connections.delete(socket) } } // web.server.close(() => { // RED.log.info('🌐[uibuilder:runtimeSetup] Custom server closed') // }) }) } // May as well register this plugin now. RED.plugins.registerPlugin('uib-runtime-plugin', { type: 'uibuilder-runtime-plugin', // optional plugin type onadd: onAdd, }) // Show the base uibuilder config on startup in the log let initialised = false RED.events.on('runtime-event', function(event) { if (event.id === 'project-update') { if ( uib.projData.enabled !== RED.settings.editorTheme?.projects?.enabled || uib.projData.activeProject !== RED.settings.get('projects').projects?.activeProject) { // MUST RESTART NODE-RED TO PICK UP PROJECT CHANGE RED.log.error(`🌐🛑[uibuilder:runtimeSetup] Detected change in Node-RED project state or active project. UIBUILDER will not work correctly until Node-RED is restarted.`) RED.events.emit('runtime-event', { id: 'uibuilder-project-change-warning', payload: { text: 'Detected change in Node-RED project state or active project. UIBUILDER will not work correctly until Node-RED is restarted. After restarting, reload the Editor to remove this message.', type: 'error', // timeout: 2000, }, }) } } // console.log(`🌐[uibuilder:runtimeSetup:runtime-event] ID: ${event.id}`) }) RED.events.on('flows:started', async function(event) { // Parse the telemetry file to load the telemetry data into the global uib object // (ensures the file exists & sends data to cloudflare if needed) // NB: Runs every time the flows are (re)started await uiblib.parseTelemetryFile() if (initialised === false ) { // make sure we only log this once, not on every deploy (flows:started is emitted on every deploy) initialised = true const myroot = uib.nodeRoot === '' ? '/' : uib.nodeRoot RED.log.info('┌─────────────────────────────────────────────────────────────────────────') RED.log.info(`│ 🌐 ${uib.moduleName} v${uib.version} initialised`) if ( uib.projData.enabled === true ) { if ( uib.projData.activeProject ) { RED.log.info(`│ Node-RED Projects enabled, using active project: "${uib.projData.activeProject}"`) } else { RED.log.info(`│ Node-RED Projects enabled, no active project. Using default location.`) } } RED.log.info(`│ Root folder: ${uib.rootFolder}`) RED.log.info(`│ Telemetry: ${uib.telemetryEnabled === true ? `On, uuid: ${uib.telemetry?.uuid}` : 'Off'}`) 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(`│ Instances: ${uib.telemetry?.uib_count} uibuilder, ${uib.telemetry?.markweb_count} markweb`) const pkgs = Object.keys(packageMgt.uibPackageJson.uibuilder.packages) RED.log.info('│ Installed packages:') if (pkgs.length === 0) { RED.log.info('│ No packages installed') } else { 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('└─────────────────────────────────────────────────────────────────────────') } }) RED.log.trace('🌐[uibuilder:runtimeSetup] ----------------- global config complete -----------------') RED.events.emit('UIBUILDER/runtimeSetupComplete', uib) } // Export the plugin definition (1), this is consumed by Node-RED on startup. module.exports = pluginDefinition