node-red-contrib-uibuilder
Version:
Easily create web UI's for Node-RED using any (or no) front-end library. VueJS and bootstrap-vue included but change as desired.
581 lines (497 loc) • 26.8 kB
JavaScript
/* eslint-disable max-params */
/** Manage ExpressJS on behalf of uibuilder
* Singleton. only 1 instance of this class will ever exist. So it can be used in other modules within Node-RED.
*
* Copyright (c) 2017-2021 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'
const path = require('path')
const util = require('util')
const fs = require('fs-extra')
const tilib = require('./tilib.js')
const serveStatic = require('serve-static')
const serveIndex = require('serve-index')
// Only used for type checking
const Express = require('express') // eslint-disable-line no-unused-vars
class Web {
/** Called when class is instantiated */
constructor() {
//#region ---- References to core Node-RED & uibuilder objects ---- //
/** @type {runtimeRED} */
this.RED = undefined
/** @type {Object} Reference link to uibuilder.js global configuration object */
this.uib = undefined
/** Reference to uibuilder's global log functions */
this.log = undefined
//#endregion ---- References to core Node-RED & uibuilder objects ---- //
//#region ---- Common variables ---- //
/** Reference to ExpressJS app instance being used by uibuilder
* Used for all other interactions with Express
*/
this.app = undefined
/** Reference to ExpressJS server instance being used by uibuilder
* Used to enable the Socket.IO client code to be served to the front-end
*/
this.server = undefined
/** Which folder to use for the fall-back front-end code (in the uibuilder module folders) */
/** Set which folder to use for the central, static, front-end resources
* in the uibuilder module folders. Services standard images, ico file and fall-back index pages
* @type {string}
*/
this.masterStatic = undefined
/** Set up a dummy ExpressJS Middleware Function */
this.dummyMiddleware = function(/** @type {Express.Request} */ req, /** @type {Express.Response} */ res, /** @type {Express.NextFunction} */ next) { next() }
//#endregion ---- ---- //
} // --- End of constructor() --- //
/** Assign uibuilder and Node-RED core vars to Class static vars.
* This makes them available wherever this MODULE is require'd.
* Because JS passess objects by REFERENCE, updates to the original
* variables means that these are updated as well.
* @param {runtimeRED} RED reference to Core Node-RED runtime object
* @param {Object} uib reference to uibuilder 'global' configuration object
* @param {Object} log reference to uibuilder log object
* param {Object} server reference to ExpressJS server being used by uibuilder
*/
//setup( RED, uib, log, server ) {
setup( RED, uib, log ) {
if ( RED ) this.RED = RED
if ( uib ) this.uib = uib
if ( uib ) this.log = log
//if ( uib ) this.server = server
/** Optional port. If set, uibuilder will use its own ExpressJS server */
// @ts-ignore
if ( RED.settings.uibuilder && RED.settings.uibuilder.port && RED.settings.uibuilder.port !== RED.settings.uiPort) uib.customServer.port = RED.settings.uibuilder.port
this.webSetup()
this.setMasterStaticFolder()
} // --- End of setup() --- //
/** Set up the appropriate ExpressJS web server references */
webSetup() {
// Reference static vars
const uib = this.uib
const RED = this.RED
//const log = this.log
/** NB: uib.nodeRoot is the root URL path for http-in/out and uibuilder nodes
* Always set to empty string if a dedicated ExpressJS app is required
* Otherwise it is set to RED.settings.httpNodeRoot */
/** We need an http server to serve the page and vendor packages. The app is used to serve up the Socket.IO client. */
if ( uib.customServer.port ) {
// Port has been specified & is different to NR's port so create a new instance of express & app
const express = require('express')
this.app = express()
/** Socket.io needs an http(s) server rather than an ExpressJS app
* As we want Socket.io on the same port, we have to create out own server
* Use https if NR itself is doing so, use same certs as NR
* TODO: Allow for https/settings overrides using uibuilder props in settings.js
* TODO: Switch from https to http/2?
*/
if ( RED.settings.https ) {
uib.customServer.type = 'https'
this.server = require('https').createServer(RED.settings.https, this.app)
} else {
this.server = require('http').createServer(this.app)
}
// Connect the server to the requested port, domain is the same as Node-RED
this.server.on('error', (err) => {
if (err.code === 'EADDRINUSE') {
this.server.close()
RED.log.error(
`[uibuilder:web:webSetup:CreateServer] ERROR: Port ${uib.customServer.port} is already in use. Cannot create uibuilder server, use a different port number and restart Node-RED`
)
return
}
})
this.server.listen(uib.customServer.port, function() {
uib.customServer.host = this.server.address().address
})
// Override the httpNodeRoot setting, has to be empty string. Use reverse proxy to change.
uib.nodeRoot = ''
} else {
// Port not specified (default) so reuse Node-RED's ExpressJS server and app
this.app = RED.httpNode // || RED.httpAdmin
this.server = RED.server
// Record the httpNodeRoot for later use
uib.nodeRoot = RED.settings.httpNodeRoot
}
} // --- End of webSetup() --- //
/** Set which folder to use for the central, static, front-end resources
* in the uibuilder module folders. Services standard images, ico file and fall-back index pages */
setMasterStaticFolder() {
// Reference static vars
const uib = this.uib
//const RED = this.RED
const log = this.log
try {
/** Will we use "compiled" version of module front-end code? */
fs.accessSync( path.join(uib.masterStaticDistFolder, 'index.html'), fs.constants.R_OK )
log.trace('[uibuilder:web:setMasterStaticFolder] Using master production build folder')
// If the ./../front-end/dist/index.html exists use the dist folder...
this.masterStatic = uib.masterStaticDistFolder
} catch (e) {
// ... otherwise, use dev resources at ./../front-end/src/
//TODO: Check if path.join(__dirname, 'src') actually exists & is accessible - else fail completely
log.trace('[uibuilder:web:setMasterStaticFolder] Using master src folder')
log.trace(' Reason for not using master dist folder: ', e.message )
this.masterStatic = uib.masterStaticSrcFolder
}
} // --- End of setMasterStaticFolder() --- //
//#region ====== Per-node instance processing ====== //
/** Setup the web resources for a specific uibuilder instance
* @param {uibNode} node
*/
instanceSetup(node) {
// Reference static vars
const uib = this.uib
//const RED = this.RED
//const log = this.log
/** Make sure that the common static folder is only loaded once */
node.commonStaticLoaded = false
// We want to add services in the right order - first load takes preference
// (1) httpMiddleware - Optional middleware from a file
this.addMiddlewareFile(node)
// (2) masterMiddleware - Generic dynamic middleware to add uibuilder specific headers & cookie
this.addMasterMiddleware(node)
// (3) customStatic - Add static route for instance local custom files
this.addInstanceStaticRoute(node)
// @ts-ignore (4) Master Static - Add static route for instance local custom files
this.app.use( tilib.urlJoin(node.url), serveStatic( this.masterStatic, uib.staticOpts ) )
/** If enabled, allow for directory listing of the custom instance folder */
if ( node.showfolder === true ) {
// @ts-ignore
this.app.use( tilib.urlJoin(node.url, 'idx'),
serveIndex( node.customFolder, {'icons':true, 'view':'details'} ),
serveStatic( node.customFolder, uib.staticOpts )
)
}
/** Serve up the uibuilder static common folder on `<url>/<commonFolderName>` and `uibuilder/<commonFolderName>` */
let commonStatic = serveStatic( uib.commonFolder, uib.staticOpts )
// @ts-ignore
this.app.use( tilib.urlJoin(node.url, uib.commonFolderName), commonStatic )
if ( node.commonStaticLoaded === false ) {
// Only load this once for all instances
//TODO: This needs some tweaking to allow the cache settings to change - currently you'd have to restart node-red.
// @ts-ignore
this.app.use( tilib.urlJoin(uib.moduleName, uib.commonFolderName), commonStatic )
node.commonStaticLoaded = true
}
}
/** (1) Optional middleware from a file */
addMiddlewareFile(node) {
// Reference static vars
const uib = this.uib
//const RED = this.RED
const log = this.log
/** Provide the ability to have a ExpressJS middleware hook.
* This can be used for custom authentication/authorisation or anything else.
*/
/** Check for <uibRoot>/.config/uibMiddleware.js, use it if present. Copy template if not exists @since v2.0.0-dev4 */
let uibMwPath = path.join(uib.configFolder, 'uibMiddleware.js')
try {
const uibMiddleware = require(uibMwPath)
if ( typeof uibMiddleware === 'function' ) {
//! TODO: Add some more checks in here (e.g. does the function have a next()?)
// @ts-ignore
this.app.use( tilib.urlJoin(node.url), uibMiddleware )
log.trace(`[uibuilder:web:addMiddlewareFile:${node.url}] uibuilder Middleware file loaded.`)
}
} catch (e) {
log.trace(`[uibuilder:web:addMiddlewareFile:${node.url}] uibuilder Middleware file failed to load. Reason: `, e.message)
}
} // --- End of addMiddlewareFile() --- //
/** (2) Generic dynamic middleware to add uibuilder specific headers & cookies */
addMasterMiddleware(node) {
// Reference static vars
//const uib = this.uib
//const RED = this.RED
//const log = this.log
let masterMiddleware = function masterMiddleware (/** @type {Express.Request} */ req, /** @type {Express.Response} */ res, /** @type {Express.NextFunction} */ next) {
//TODO: X-XSS-Protection only needed for html (and js?), not for css, etc
// Help reduce risk of XSS and other attacks
res.setHeader('X-XSS-Protection','1;mode=block')
res.setHeader('X-Content-Type-Options','nosniff')
//res.setHeader('X-Frame-Options','SAMEORIGIN')
//res.setHeader('Content-Security-Policy',"script-src 'self'")
// Tell the client that uibuilder is being used (overides the default "ExpressJS" entry)
res.setHeader('x-powered-by','uibuilder')
// Tell the client what Socket.IO namespace to use,
// trim the leading slash because the cookie will turn it into a %2F
res.setHeader('uibuilder-namespace', node.url)
res.cookie('uibuilder-namespace', node.url, {path: node.url, sameSite: true}) // tilib.trimSlashes(node.url), {path: node.url, sameSite: true})
next()
}
// @ts-ignore
this.app.use( tilib.urlJoin(node.url), masterMiddleware )
} // --- End of addMasterMiddleware() --- //
/** (3) Add static ExpressJS route for instance local custom files */
addInstanceStaticRoute(node) {
// Reference static vars
const uib = this.uib
//const RED = this.RED
const log = this.log
let customStatic = 'dist'
try {
// Check if local dist folder contains an index.html & if NR can read it - fall through to catch if not
fs.accessSync( path.join(node.customFolder, 'dist', 'index.html'), fs.constants.R_OK )
// If the ./dist/index.html exists use the dist folder...
log.trace(`[uibuilder:web:addInstanceStaticRoute:${node.url}] Using local dist folder`)
// NOTE: You are expected to have included vendor packages in
// a build process so we are not loading them here
} catch (e) {
// dist not being used or not accessible, use src
log.trace(`[uibuilder:web:addInstanceStaticRoute:${node.url}] Dist folder not in use or not accessible. Using local src folder`, e.message )
//TODO: Check if folder actually exists & is accessible
customStatic = 'src'
}
// @ts-ignore
this.app.use( tilib.urlJoin(node.url), serveStatic( path.join(node.customFolder, customStatic), uib.staticOpts ) )
} // --- End of addInstanceStaticRoute() --- //
/** Remove all of the app.use middleware for this instance */
removeInstanceMiddleware(node) {
// We need to remove the app.use paths too as they will be recreated on redeploy
// we check whether the regex string matches the current node.url, if so, we splice it out of the stack array
var removePath = []
var urlRe = new RegExp('^' + tilib.escapeRegExp('/^\\' + tilib.urlJoin(node.url)) + '.*$')
var urlReVendor = new RegExp('^' + tilib.escapeRegExp('/^\\/uibuilder\\/vendor\\') + '.*$')
// For each entry on ExpressJS's server stack...
this.app._router.stack.forEach( function(r, i, _stack) { // eslint-disable-line no-unused-vars
// Check whether the URL matches a vendor path...
let rUrlVendor = r.regexp.toString().replace(urlReVendor, '')
// If it DOES NOT, then...
if (rUrlVendor !== '') {
// Check whether the URL is a uibuilder one...
let rUrl = r.regexp.toString().replace(urlRe, '')
// If it IS ...
if ( rUrl === '' ) {
// Mark it for removal because it will be re-created by nodeGo() when the nodes restart
removePath.push( i )
// @since 2017-10-15 Nasty bug! Splicing changes the length of the array so the next splice is wrong!
//app._router.stack.splice(i,1)
}
}
// NB: We do not want to remove the vendor URL's because they are only created ONCE when Node-RED initialises
})
// TODO Remove instance debug admin route `RED.httpAdmin.get('/uib/instance/${node.url}')`
// @since 2017-10-15 - proper way to remove array entries - in reverse order so the ids don't change - doh!
for (var i = removePath.length -1; i >= 0; i--) {
this.app._router.stack.splice(removePath[i],1)
}
} // --- End of removeAllMiddleware() --- //
//#endregion ====== Per-node instance processing ====== //
//#region ====== Package Management ====== //
/** Compare the in-memory package list against packages actually installed.
* Also check common packages installed against the master package list in case any new ones have been added.
* Updates the package list file and uib.installedPackages
* param {Object} vendorPaths Schema: {'<npm package name>': {'url': vendorPath, 'path': installFolder, 'version': packageVersion, 'main': mainEntryScript} }
* @param {string} newPkg Default=''. Name of a new package to be checked for in addition to existing.
* @return {Object} uib.installedPackages
*/
checkInstalledPackages(newPkg='') {
// Reference static vars
const uib = this.uib
const RED = this.RED
const log = this.log
const app = this.app
const debug = false
const userDir = RED.settings.userDir
let installedPackages = uib.installedPackages
let pkgList = []
let masterPkgList = []
let merged = []
//region --- get package lists from files --- //
// Read packageList and masterPackageList from their files
try {
pkgList = fs.readJsonSync(path.join(uib.configFolder, uib.packageListFilename))
} catch (err) {
// not an issue
}
try {
masterPkgList = fs.readJsonSync(path.join(uib.configFolder, uib.masterPackageListFilename))
} catch (err) {
// no op
}
// If neither can be found, that's an error
if ( (pkgList.length === 0) && (masterPkgList.length === 0) ) {
log.error(`[uibuilder:web:checkInstalledPackages] Neither packageList nor masterPackageList could be read from: ${uib.configFolder}`)
return null
}
// Make sure we have socket.io in the list
masterPkgList.push('socket.io')
//endregion --- get package lists from files --- //
// Add in the new package as well if requested
if (newPkg !== '') {
pkgList.push(newPkg)
}
// Merge and de-dup to get a complete list
merged = tilib.mergeDedupe(Object.keys(installedPackages), pkgList, masterPkgList)
// For each entry in the complete list ...
merged.forEach( (pkgName, _i) => { // eslint-disable-line no-unused-vars
// flags
let pkgExists = false
let pj = null // package details if found
// Check to see if folder names present in <userDir>/node_modules
const pkgFolder = tilib.findPackage(pkgName, userDir)
// Check whether package is really installed (exists)
if ( pkgFolder !== null ) {
// Get the package.json
pj = tilib.readPackageJson( pkgFolder )
/** The folder delete for npm remove happens async so it may
* still exist when we check. But the package.json will have been removed
* so we don't process the entry unless package.json actually exists
*/
if ( ! Object.prototype.hasOwnProperty.call(pj, 'ERROR') ) {
// We only know for sure package exists now
pkgExists = true
}
}
// Check to see if the package is in the current list
const isInCurrent = Object.prototype.hasOwnProperty.call(installedPackages, pkgName)
if ( pkgExists ) {
// If package does NOT exist in current - add it now
if ( ! isInCurrent ) {
// Add to current & mark for loading
installedPackages[pkgName] = {}
installedPackages[pkgName].loaded = false
}
// Update package info
installedPackages[pkgName].folder = pkgFolder
installedPackages[pkgName].url = ['..', uib.moduleName, 'vendor', pkgName].join('/')
// Find installed version
installedPackages[pkgName].version = pj.version
// Find homepage
installedPackages[pkgName].homepage = pj.homepage
// Find main entry point (or '')
installedPackages[pkgName].main = pj.main || ''
/** Try to guess the browser entry point (or '')
* @since v3.2.1 Fix for packages misusing the browser property - might be an object see #123
*/
let browserEntry = ''
if ( pj.browser ) {
if ( typeof pj.browser === 'string' ) browserEntry = pj.browser
}
if ( browserEntry === '' ) {
browserEntry = pj.jsdelivr || pj.unpkg || ''
}
installedPackages[pkgName].browser = browserEntry
// Replace generic with specific entries if we know them
if ( pkgName === 'socket.io' ) {
//installedPackages[pkgName].url = '../uibuilder/socket.io/socket.io.js'
installedPackages[pkgName].main = 'socket.io.js'
}
// If we need to load it & we have app available
if ( (installedPackages[pkgName].loaded === false) && (app !== undefined) ) {
/** Add a static path to serve up the files */
installedPackages[pkgName].loaded = this.servePackage(pkgName)
}
} else { // (package not actually installed)
// If in current, flag for unloading then delete from current
if ( isInCurrent ) { // eslint-disable-line no-lonely-if
if ( app !== undefined) {
installedPackages[pkgName].loaded = this.unservePackage(pkgName)
if (debug) console.log('[uibuilder:web:checkInstalledPackages] package unserved ', pkgName)
}
delete installedPackages[pkgName]
if (debug) console.log('[uibuilder:web:checkInstalledPackages] package deleted from installedPackages ', pkgName)
}
}
})
//uib.installedPackages = installedPackages
// Write packageList back to file
try {
fs.writeJsonSync(path.join(uib.configFolder,uib.packageListFilename), Object.keys(installedPackages), {spaces:2})
} catch(e) {
log.error(`[uibuilder:web:checkInstalledPackages] Could not write ${uib.packageListFilename} in ${uib.configFolder}`, e)
}
return uib.installedPackages
} // ---- End of checkInstalledPackages ---- //
/** Add an installed package to the ExpressJS app to allow access from URLs
* @param {string} packageName Name of the front-end npm package we are trying to add
* @returns {boolean} True if loaded, false otherwise
*/
servePackage(packageName) {
// Reference static vars
const uib = this.uib
const RED = this.RED
const log = this.log
const app = this.app
let userDir = RED.settings.userDir
let pkgDetails = null
let installedPackages = uib.installedPackages
// uib.installedPackages[packageName] MUST exist and be populated (done by uiblib.checkInstalledPackages())
if ( Object.prototype.hasOwnProperty.call(installedPackages, packageName) ) {
pkgDetails = installedPackages[packageName]
} else {
log.error('[uibuilder:web:servePackage] Failed to find package in uib.installedPackages')
return false
}
// Where is the node_modules folder for this package?
const installFolder = pkgDetails.folder
if (installFolder === '' ) {
log.error(`[uibuilder:web:servePackage] Failed to add user vendor path - no install folder found for ${packageName}. Try doing "npm install ${packageName} --save" from ${userDir}`)
return false
}
// What is the URL for this package? Remove the leading "../"
var vendorPath
try {
vendorPath = pkgDetails.url.replace('../','/') // "../uibuilder/vendor/socket.io" tilib.urlJoin(uib.moduleName, 'vendor', packageName)
} catch (e) {
log.error(`[uibuilder:web:servePackage] ${packageName} `, e)
return false
}
log.trace(`[uibuilder:web:servePackage] Adding user vendor path: ${util.inspect({'url': vendorPath, 'path': installFolder})}`)
try {
app.use( vendorPath, /**function (req, res, next) {
// TODO Allow for a test to turn this off
// if (true !== true) {
// next('router')
// }
next() // pass control to the next handler
}, */ serveStatic(installFolder, uib.staticOpts) )
return true
} catch (e) {
log.error(`[uibuilder:web:servePackage] app.use failed. vendorPath: ${vendorPath}, installFolder: ${installFolder}`, e)
return false
}
} // ---- End of servePackage ---- //
/** Remove an installed package from the ExpressJS app
* @param {string} packageName Name of the front-end npm package we are trying to add
* @returns {boolean} True if unserved, false otherwise
*/
unservePackage(packageName) {
// Reference static vars
//const uib = this.uib
//const RED = this.RED
//const log = this.log
const app = this.app
let pkgReStr = `/^\\/uibuilder\\/vendor\\/${packageName}\\/?(?=\\/|$)/i`
let done = false
// For each entry on ExpressJS's server stack...
app._router.stack.some( function(r, i) {
if ( r.regexp.toString() === pkgReStr ) {
// We can splice inside the array only because we will only do a single one.
app._router.stack.splice(i,1)
done = true
return true
}
return false
})
return done
} // ---- End of unservePackage ---- //
//#endregion ====== Package Management ====== //
} // ==== End of Web Class Definition ==== //
/** Singleton model. Only 1 instance of UibSockets should ever exist.
* Use as: `const sockets = require('./socket.js')`
*/
module.exports = new Web()
// EOF