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.
956 lines (805 loc) • 45.9 kB
JavaScript
/** Manage Socket.IO 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-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.
*/
/* eslint-disable class-methods-use-this, sonarjs/no-duplicate-string, max-params */
/** --- Type Defs ---
* @typedef {import('../../typedefs.js').runtimeRED} runtimeRED
* @typedef {import('../../typedefs.js').MsgAuth} MsgAuth
* @typedef {import('../../typedefs.js').uibNode} uibNode
* @typedef {import('../../typedefs.js').uibConfig} uibConfig
* @typedef {import('express')} Express
*/
const { join } = require('path')
const { existsSync, getFileMeta } = require('./fs')
const socketio = require('socket.io')
const { urlJoin } = require('./tilib') // General purpose library (by Totally Information)
const { setNodeStatus } = require('./uiblib') // Utility library for uibuilder
// const security = require('./sec-lib') // uibuilder security module
/** Parse x-forwarded-for headers.
* Borrowed from https://github.com/pbojinov/request-ip/blob/master/src/index.js
* @param {string|string[]} value - The value to be parsed.
* @returns {string|null} First known IP address, if any.
*/
function getClientIpFromXForwardedFor(value) {
if (!value) return null
if (Array.isArray(value)) value = value[0]
// x-forwarded-for may return multiple IP addresses in the format:
// "client IP, proxy 1 IP, proxy 2 IP"
// Therefore, the right-most IP address is the IP address of the most recent proxy
// and the left-most IP address is the IP address of the originating client.
// source: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-For
// Azure Web App's also adds a port for some reason, so we'll only use the first part (the IP)
const forwardedIps = value.split(',').map((e) => {
const ip = e.trim()
if (ip.includes(':')) {
const splitted = ip.split(':')
// make sure we only use this if it's ipv4 (ip:port)
if (splitted.length === 2) {
return splitted[0]
}
}
return ip
})
// Sometimes IP addresses in this header can be 'unknown' (http://stackoverflow.com/a/11285650).
// Therefore taking the right-most IP address that is not unknown
// A Squid configuration directive can also set the value to "unknown" (http://www.squid-cache.org/Doc/config/forwarded_for/)
for (let i = 0; i < forwardedIps.length; i++) {
if (forwardedIps[i] !== 'unknown') {
return forwardedIps[i]
}
}
// If no value in the split list is an ip, return null
return null
}
/** Get client real ip address
* Borrowed from https://github.com/pbojinov/request-ip/blob/master/src/index.js
* @param {socketio.Socket} socket Socket.IO socket object
* @returns {string | string[] | undefined} Best estimate of the client's real IP address
*/
function getClientRealIpAddress(socket) {
const headers = socket.request.headers
// Standard headers used by Amazon EC2, Heroku, and others.
if (headers['x-client-ip']) return headers['x-client-ip']
// Load-balancers (AWS ELB) or proxies.
const xForwardedFor = getClientIpFromXForwardedFor(headers['x-forwarded-for'])
if (xForwardedFor) return xForwardedFor
// Cloudflare. @see https://support.cloudflare.com/hc/en-us/articles/200170986-How-does-Cloudflare-handle-HTTP-Request-headers-
// CF-Connecting-IP - applied to every request to the origin.
if (headers['cf-connecting-ip']) return headers['cf-connecting-ip']
// DigitalOcean. @see https://www.digitalocean.com/community/questions/app-platform-client-ip
// DO-Connecting-IP - applied to app platform servers behind a proxy.
if (headers['do-connecting-ip']) return headers['do-connecting-ip']
// Fastly and Firebase hosting header (When forwared to cloud function)
if (headers['fastly-client-ip']) return headers['fastly-client-ip']
// Akamai and Cloudflare: True-Client-IP.
if (headers['true-client-ip']) return headers['true-client-ip']
// Default nginx proxy/fcgi alternative to x-forwarded-for, used by some proxies.
if (headers['x-real-ip']) return headers['x-real-ip']
// (Rackspace LB and Riverbed's Stingray)
// http://www.rackspace.com/knowledge_center/article/controlling-access-to-linux-cloud-sites-based-on-the-client-ip-address
// https://splash.riverbed.com/docs/DOC-1926
if (headers['x-cluster-client-ip']) return headers['x-cluster-client-ip']
if (headers['x-forwarded']) return headers['x-forwarded']
if (headers['forwarded-for']) return headers['forwarded-for']
if (headers.forwarded) return headers.forwarded
// Google Cloud App Engine
// https://cloud.google.com/appengine/docs/standard/go/reference/request-response-headers
if (headers['x-appengine-user-ip']) return headers['x-appengine-user-ip']
// else get ip from socket.request that returns the reference to the request that originated the underlying engine.io Client
if ( socket.request?.connection?.remoteAddress ) return socket.request.connection.remoteAddress
// else get ip from socket.handshake that is a object that contains handshake details
return socket.handshake.address
} // --- End of getClientRealIpAddress --- //
/** Get client real ip address - NB: Optional chaining (?.) is node.js v14 not v12
* @param {socketio.Socket} socket Socket.IO socket object
* @param {uibNode} node Reference to the uibuilder node instance
* @returns {string | string[] | undefined} Best estimate of the client's real IP address
*/
function getClientPageName(socket, node) {
let pageName = socket.handshake.auth.pathName.replace(`/${node.url}/`, '')
if ( pageName.endsWith('/') ) pageName += 'index.html'
if ( pageName === '' ) pageName = 'index.html'
return pageName
} // --- End of getClientPageName --- //
class UibSockets {
// TODO: Replace _XXX with #XXX once node.js v14 is the minimum supported version
/** Flag to indicate whether setup() has been run
* @type {boolean}
* @protected
*/
// _isConfigured = false
/** Called when class is instantiated */
constructor() {
// setup() has not yet been run
this._isConfigured = false
//#region ---- References to core Node-RED & uibuilder objects ---- //
/** @type {runtimeRED|undefined} */
this.RED = undefined
/** @type {uibConfig|undefined} Reference link to uibuilder.js global configuration object */
this.uib = undefined
/** Reference to uibuilder's global log functions */
this.log = 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
//#endregion ---- References to core Node-RED & uibuilder objects ---- //
//#region ---- Common variables ---- //
/** URI path for accessing the socket.io client from FE code. Based on the uib node instance URL.
* @constant {string} uib_socketPath */
this.uib_socketPath = undefined
/** An instance of Socket.IO Server */
this.io = undefined
/** Collection of Socket.IO namespaces
* Each namespace correstponds to a uibuilder node instance and must have a unique namespace name that matches the unique URL parameter for the node.
* The namespace is stored in the this.ioNamespaces object against a property name matching the URL so that it can be referenced later.
* Because this is a Singleton object, any reference to this module can access all of the namespaces (by url).
* The namespace has some uib extensions that track the originating node id (searchable in Node-RED), the number of connected clients
* and the number of messages recieved.
* @type {!Object<string, socketio.Namespace>}
*/
this.ioNamespaces = {}
//#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 {uibConfig} uib reference to uibuilder 'global' configuration object
* @param {Express} server reference to ExpressJS server being used by uibuilder
*/
setup( uib, server ) {
if ( !uib || !server ) throw new Error('[uibuilder:socket.js:setup] Called without required parameters or uib and/or server are undefined.')
if (uib.RED === null) throw new Error('[uibuilder:socket.js:setup] uib.RED is null')
// Prevent setup from being called more than once
if ( this._isConfigured === true ) {
uib.RED.log.warn('🌐⚠️[uibuilder:web:setup] Setup has already been called, it cannot be called again.')
return
}
/** reference to Core Node-RED runtime object */
this.RED = uib.RED
this.uib = uib
this.log = uib.RED.log
this.server = server
// TODO: Replace _XXX with #XXX once node.js v14 is the minimum supported version
this._socketIoSetup()
if (uib.configFolder === null) throw new Error('[uibuilder:socket.js:setup] uib.configFolder is null')
// If available, set up optional outbound msg middleware
this.outboundMsgMiddleware = function outboundMsgMiddleware( msg, url, channel ) { return null }
// Try to load the sioMsgOut middleware function - sioMsgOut applies to all outgoing msgs
const mwfile = join(uib.configFolder, uib.sioMsgOutMwName)
if ( existsSync(mwfile) ) { // not interested if the file doesn't exist
try {
const sioMsgOut = require( mwfile )
if ( typeof sioMsgOut === 'function' ) { // if exported, has to be a function
this.outboundMsgMiddleware = sioMsgOut
this.log.trace('🌐[uibuilder:socket:setup] sioMsgOut Middleware loaded successfully.')
} else {
this.log.warn('🌐⚠️[uibuilder:socket:setup] sioMsgOut Middleware failed to load - check that uibRoot/.config/sioMsgOut.js has a valid exported fn.')
}
} catch (e) {
this.log.warn(`🌐⚠️[uibuilder:socket:setup] sioMsgOut middleware Failed to load. Reason: ${e.message}`)
}
}
this._isConfigured = true
} // --- End of setup() --- //
/** Holder for Socket.IO - we want this to survive redeployments of each node instance
* so that existing clients can be reconnected.
* Start Socket.IO - make sure the right version of SIO is used so keeping this separate from other
* modules that might also use it (path). This is only needed ONCE for ALL uib.instances of this node.
* Must only be run once and so is made an ECMA2018 private class method
* @private
*/
_socketIoSetup() {
// Reference static vars
const uib = this.uib
const RED = this.RED
const log = this.log
const server = this.server
if (uib === undefined) throw new Error('uib is undefined')
if (RED === undefined) throw new Error('RED is undefined')
if (log === undefined) throw new Error('log is undefined')
const uibSocketPath = this.uib_socketPath = urlJoin(uib.nodeRoot, uib.moduleName, 'vendor', 'socket.io')
log.trace(`🌐[uibuilder[:socket:socketIoSetup] Socket.IO initialisation - Socket Path=${uibSocketPath}, CORS Origin=*` )
// Socket.Io server options, see https://socket.io/docs/v4/server-options/
let ioOptions = {
'path': uibSocketPath,
// NOTE: webtransport requires HTTP/3 and TLS. HTTP/2 & 3 not yet available in Node.js
// transports: ['polling', 'websocket', 'webtransport'],
serveClient: false, // No longer required from v7
connectionStateRecovery: {
// the backup duration of the sessions and the packets
maxDisconnectionDuration: 120000, // Default = 2 * 60 * 1000 = 120000,
// whether to skip middlewares upon successful recovery
skipMiddlewares: true, // Default = true
},
// https://github.com/expressjs/cors#configuration-options, https://socket.io/docs/v3/handling-cors/
cors: {
origin: '*',
// allowedHeaders: ['x-clientid'],
},
/* // Socket.Io 3+ CORS is disabled by default, also options have changed.
// for CORS need to handle preflight request explicitly 'cause there's an
// Allow-Headers:X-ClientId in there. see https://socket.io/docs/v4/handling-cors/
handlePreflightRequest: (req, res) => {
res.writeHead(204, {
'Access-Control-Allow-Origin': req.headers['origin'], // eslint-disable-line dot-notation
'Access-Control-Allow-Methods': 'GET,POST',
'Access-Control-Allow-Headers': 'X-ClientId',
'Access-Control-Allow-Credentials': true,
})
res.end()
}, */
}
// Merge in overrides from settings.js if given. NB: settings.uibuilder.socketOptions will override the above defaults.
if ( RED.settings.uibuilder && RED.settings.uibuilder.socketOptions ) {
ioOptions = Object.assign( {}, ioOptions, RED.settings.uibuilder.socketOptions )
}
// @ts-ignore ts(2769)
this.io = new socketio.Server(server, ioOptions) // listen === attach
// Runs when a connection or long-poll request happens - allows overrides of socket.request.headers (=== socket.handshake.headers)
this.io.engine.on('headers', (headers, req) => {
// Optional hook to override headers - set in settings.js uibuilder.hooks.socketIoHeaders
this.hooks('socketIoHeaders', { headers, req })
})
} // --- End of socketIoSetup() --- //
/** Allow the isConfigured flag to be read (not written) externally
* @returns {boolean} True if this class as been configured
*/
get isConfigured() {
return this._isConfigured
}
/** Run socket related hooks if present
* Hooks must be a function and must return true or false, if not present, return true
* @param {string} hookName Name of the hook function to call
* @param {object} data Data to pass to the hook fn. Content depends on which hook.
* @returns {boolean} True to allow message flow, false to block
*/
hooks(hookName, data) {
if (!this.uib) throw new Error('uib is undefined')
const RED = this.RED
let out = true
if (!RED?.settings?.uibuilder?.hooks?.[hookName]) return
const hook = RED.settings.uibuilder.hooks[hookName]
if (typeof hook === 'function') {
try {
out = RED.settings.uibuilder.hooks[hookName](data)
} catch (e) {
this.log.warn(`🌐⚠️[uibuilder:socket:hooks] Could not run 'uibuilder.hooks.${hookName}' hook in settings.js. ${e.message}`, { e, data })
}
}
return out
}
// ? Consider adding isConfigured checks on each method?
/** Output a msg to the front-end.
* @param {object} msg The message to output, include msg._socketId to send to a single client
* @param {uibNode} node Reference to the uibuilder node instance
* @param {string=} channel Optional. Which channel to send to (see uib.ioChannels) - defaults to client
*/
sendToFe( msg, node, channel ) {
const uib = this.uib
const log = this.log
const url = node.url
if (!uib) throw new Error('uib is undefined. UibSockets:sendToFe')
if (!log) throw new Error('log is undefined. UibSockets:sendToFe')
if (!url) throw new Error('url is undefined. UibSockets:sendToFe')
if ( !channel ) channel = uib.ioChannels.client
const ioNs = this.ioNamespaces[url]
const socketId = msg._socketId || undefined
// Control msgs should say where they came from
if ( channel === uib.ioChannels.control && !msg.from ) msg.from = 'server'
// Run uibuilder.hooks.msgSending hook - NOTE: msg might be amended by the hook
if (this.hooks('msgSending', { msg, node }) === false) {
log.warn(`🌐⚠️[uibuilder:socket:sendToFe] outbound msg blocked for "${node.url}" by "uibuilder.hooks.msgSending" hook in settings.js`)
}
// Process outbound middleware (middleware is loaded in this.setup)
try {
this.outboundMsgMiddleware( msg, url, channel, ioNs )
} catch (e) {
log.warn(`🌐⚠️[uibuilder:socket:sendToFe] outboundMsgMiddleware middleware failed to run. Reason: ${e.message}`)
}
// TODO: Sending should have some safety validation on it. Is msg an object? Is channel valid?
// pass the complete msg object to the uibuilder client
if (socketId) { // Send to specific client
log.trace(`🌐[uibuilder[:socket.js:sendToFe:${url}] msg sent on to client ${socketId}. Channel: ${channel}. ${JSON.stringify(msg)}`)
ioNs.to(socketId).emit(channel, msg)
} else { // Broadcast
log.trace(`🌐[uibuilder[:socket.js:sendToFe:${url}] msg sent on to ALL clients. Channel: ${channel}. ${JSON.stringify(msg)}`)
ioNs.emit(channel, msg)
}
} // ---- End of sendToFe ---- //
/** Output a normal msg to the front-end. Can override socketid. NOTE:
* Applies the msgReceived hook if present
* Only used for: function-node:uib.send, auto-reload on edit in admin-api-v2.js and Post:replaceTemplate in admin-api-v3.js
* @param {object} msg The message to output
* @param {uibNode} node WARNING: Not a full reference to a node instance, only node.url is available
* @param {string=} socketId Optional. If included, only send to specific client id (mostly expecting this to be on msg._socketID so not often required)
*/
sendToFe2(msg, node, socketId) { // eslint-disable-line class-methods-use-this
const uib = this.uib
const ioNs = this.ioNamespaces[node.url]
if (uib === undefined) throw new Error('uib is undefined')
if (this.log === undefined) throw new Error('this.log is undefined')
if (socketId) msg._socketId = socketId
// Run uibuilder.hooks.msgSending hook - NOTE: msg might be amended by the hook
if (this.hooks('msgSending', { msg, node }) === false) {
this.log.warn(`🌐⚠️[uibuilder:socket:sendToFe2] outbound msg blocked for "${node.url}" by "uibuilder.hooks.msgSending" hook in settings.js`)
}
// TODO: This should have some safety validation on it
if (msg._socketId) {
this.log.trace(`🌐[uibuilder[:socket:sendToFe2:${node.url}] msg sent on to client ${msg._socketId}. Channel: ${uib.ioChannels.server}. ${JSON.stringify(msg)}`)
ioNs.to(msg._socketId).emit(uib.ioChannels.server, msg)
} else {
this.log.trace(`🌐[uibuilder[:socket:sendToFe2:${node.url}] msg sent on to ALL clients. Channel: ${uib.ioChannels.server}. ${JSON.stringify(msg)}`)
ioNs.emit(uib.ioChannels.server, msg)
}
} // ---- End of sendToFe2 ---- //
/** Send a uibuilder control message out of port #2. NOTE:
* Applies the msgReceived hook if present
* this.getClientDetails is used before calling this if client details needed
* @param {object} msg The message to output
* @param {uibNode} node Reference to the uibuilder node instance
* @param {string} [from] Optional. Trace what source fn triggered the send
*/
sendCtrlMsg(msg, node, from = '') {
this.log.trace(`🌐[uibuilder[:sendCtrlMsg] FROM: '${from}'`)
// Run uibuilder.hooks.msgReceived hook - NOTE: msg might be amended by the hook
if (this.hooks('msgReceived', { msg, node }) === false) {
this.log.warn(`🌐⚠️[uibuilder:socket:sendToFe] Control msg output blocked for "${node.url}" by "uibuilder.hooks.msgReceived" hook in settings.js`)
return
}
node.send( [null, msg] )
}
/** Get client details for including in Node-RED messages
* @param {socketio.Socket} socket Reference to client socket connection
* @param {uibNode} node Reference to the uibuilder node instance
* @returns {object} Extracted key client information
*/
getClientDetails(socket, node) {
// Add page name meta to allow caches and other flows to send back to specific page
// Note, could use socket.handshake.auth.pageName instead
let pageName
const headers = socket.request.headers
const handshake = socket.handshake
if ( handshake.auth.pathName ) pageName = getClientPageName(socket, node)
const realClientIP = getClientRealIpAddress(socket)
// WARNING: The socket.handshake data can only ever be changed by the client when it (re)connects
const client = {}
client._uib = {
/** What was the originating uibuilder URL */
'url': node.url,
'_socketId': socket.id,
/** Is this client reconnected after temp loss? */
'recovered': socket.recovered,
/** Do our best to get the actual IP addr of client despite any Proxies */
'ip': realClientIP,
/** The referring webpage, should be the full URL of the uibuilder page */
'referer': headers.referer,
// Let the flow know what v of uib client is in use
'version': handshake.auth.clientVersion,
/** What is the stable client id (set by uibuilder, retained till browser restart) */
'clientId': handshake.auth.clientId,
/** What is the client tab identifier (set by uibuilder modern client) */
'tabId': handshake.auth.tabId,
/** What was the originating page name (for SPA's) */
'pageName': pageName,
/** The browser's URL parameters */
'urlParams': handshake.auth.urlParams,
/** How many times has this client reconnected (e.g. after sleep) */
'connections': handshake.auth.connectedNum,
/** True if https/wss */
'tls': handshake.secure,
/** When the client connected to the server */
'connectedTimestamp': (new Date(handshake.issued)).toISOString(),
// 'browserConnectTimestamp': handshake.auth.browserConnectTimestamp,
'connectHeaders': headers,
}
// @ts-ignore
const clientTimeDifference = (new Date(handshake.issued)) - (new Date(handshake.auth.browserConnectTimestamp))
// Only include this if The difference between the timestamps is > 1 minute - output is in milliseconds
if (clientTimeDifference > 60000) client._uib.clientTimeDifference = clientTimeDifference
let authProvider
if (headers['cf-access-authenticated-user-email']) authProvider = 'CloudFlare Access'
else if (handshake.auth?.user?.userId) authProvider = 'FlowFuse'
else if (headers['x-user-id']) authProvider = 'Keycloak'
else if (headers['x-authentik-uid']) authProvider = 'Authentik'
else if (headers['remote-user'] || headers['x-remote-user']) authProvider = 'Custom'
else if (headers['x-forwarded-user']) authProvider = 'Proxied Custom'
const userID = headers['cf-access-user'] || headers['cf-access-authenticated-user-email'] || handshake.auth?.user?.userId || headers['x-authentik-uid'] || headers['remote-user'] || headers['x-remote-user'] || headers['x-forwarded-user'] || headers['x-user-id'] || undefined
// client._client is ONLY added for recognised authenticated clients
if (authProvider !== undefined && userID !== undefined) {
const email = headers['cf-access-authenticated-user-email'] || headers['x-authentik-email'] || headers['remote-email'] || headers['x-user-email'] || undefined
const name = headers['x-authentik-name'] || headers['remote-name'] || headers['x-remote-name'] || handshake.auth?.user?.name
client._client = {
userId: userID,
socketId: socket.id,
email: email,
provider: authProvider,
agent: headers['user-agent'] || null,
ip: realClientIP,
host: headers['host'],
name: name,
}
if (headers['x-forwarded-groups']) client._client.groups = headers['x-forwarded-groups']
if (headers['x-authentik-groups']) client._client.groups = headers['x-authentik-groups']
if (headers['x-authentik-username']) client._client.username = headers['x-authentik-username']
if (headers['x-user-role']) client._client.role = headers['x-user-role']
if (handshake.auth?.user?.image) client._client.image = handshake.auth.user.image
}
// Run uibuilder.hooks.msgReceived hook if it exists - NOTE: msg might be amended by the hook
// Allows client data to be amended
this.hooks('clientDetails', { client, socket, node })
return client
}
/** Get a uib node instance namespace
* @param {string} url The uibuilder node instance's url (identifier)
* @returns {socketio.Namespace} Return a reference to the namespace of the specified uib instance for convenience in core code
*/
getNs(url) {
return this.ioNamespaces[url]
}
/** Send a node-red msg either directly out of the node instance OR via return event name. NOTE:
* Applies the msgReceived hook if present
* @param {object} msg Message object received from a client
* @param {uibNode} node Reference to the uibuilder node instance
*/
sendIt(msg, node) {
const RED = this.RED
// Run uibuilder.hooks.msgReceived hook - NOTE: msg might be amended by the hook
if (this.hooks('msgReceived', { msg, node }) === false) {
this.log.warn(`🌐⚠️[uibuilder:socket:sendToFe] msg output blocked for "${node.url}" by "uibuilder.hooks.msgReceived" hook in settings.js`)
return
}
if ( msg?._uib?.originator && (typeof msg._uib.originator === 'string') ) {
RED.events.emit(`uibuilder/return-to-sender/${msg._uib.originator}`, msg)
} else {
node.send(msg)
}
}
/** Socket listener fn for standard msgs from clients - NOTE:
* The optional sioUse middleware is applied BEFORE this
* The optional msgReceived hook is applied AFTER this
* @param {object} msg Message object received from a client
* @param {socketio.Socket} socket Reference to the socket for this node
* @param {uibNode} node Reference to the uibuilder node instance
*/
listenFromClientStd(msg, socket, node) {
const log = this.log
if (log === undefined) throw new Error('log is undefined')
node.rcvMsgCount++
log.trace(`🌐[uibuilder[:socket:${node.url}] Data received from client, ID: ${socket.id}, Msg: ${JSON.stringify(msg)}`)
// Make sure the incoming msg is a correctly formed Node-RED msg
switch ( typeof msg ) {
case 'string':
case 'number':
case 'boolean':
msg = { 'topic': node.topic, 'payload': msg }
}
// If the sender hasn't added msg._socketId, add the Socket.id now
if ( !Object.prototype.hasOwnProperty.call(msg, '_socketId') ) msg._socketId = socket.id
const { _uib, _client } = this.getClientDetails(socket, node)
// If required, add/merge the client details to the msg using msg._uib, remove if not required
if (node.showMsgUib) {
if (!msg._uib) msg._uib = _uib
else {
msg._uib = {
...msg._uib,
..._uib
}
}
}
// Do NOT remove msg._uib here!
if (_client) msg._client = _client
// Send out the message for downstream flows
// TODO: This should probably have safety validations!
this.sendIt(msg, node)
} // ---- End of listenFromClient ---- //
/** Socket listener fn for control msgs from clients - NOTE:
* The optional sioUse middleware is applied BEFORE this
* The optional msgReceived hook is applied AFTER this
* @param {object} msg Message object received from a client
* @param {socketio.Socket} socket Reference to the socket for this node
* @param {uibNode} node Reference to the uibuilder node instance
*/
listenFromClientCtrl(msg, socket, node) {
const log = this.log
if (log === undefined) throw new Error('log is undefined')
node.rcvMsgCount++
log.trace(`🌐[uibuilder[:socket:${node.url}] Control Msg from client, ID: ${socket.id}, Msg: ${JSON.stringify(msg)}`)
// Make sure the incoming msg is a correctly formed Node-RED msg
switch ( typeof msg ) {
case 'string':
case 'number':
case 'boolean':
msg = { 'uibuilderCtrl': msg }
}
// Apply standard client details to the control msg
const { _uib, _client } = this.getClientDetails(socket, node)
msg = { ...msg, ..._uib }
if (_client) msg._client = _client
// Control msgs should say where they came from
msg.from = 'client'
if ( !msg.topic ) msg.topic = node.topic
// Can we handle a control request directly? If not, send it out of port #2
switch (msg.uibuilderCtrl) { // eslint-disable-line sonarjs/no-small-switch
case 'get page meta': {
// This returns the data straight back to the requesting client, does not output to port #2
getFileMeta(join(node.customFolder, node.sourceFolder, msg.pageName))
.then( (fstats) => {
fstats.pageName = msg.pageName
// Send the details back to the FE
const newMsg = {
payload: fstats,
uibuilderCtrl: 'get page meta',
_socketId: msg._socketId,
topic: msg.topic,
}
this.sendToFe( newMsg, node, this.uib.ioChannels.control )
return fstats
})
.catch( (err) => {
log.error(err)
})
break
}
default: {
this.sendCtrlMsg(msg, node, 'listenFromClientCtrl')
break
}
}
}
/** Add a new Socket.IO NAMESPACE (for each uib instance) - also creates std & ctrl and other listeners on connection
* Each namespace correstponds to a uibuilder node instance and must have a unique namespace name that matches the unique URL parameter for the node.
* The namespace is stored in the this.ioNamespaces object against a property name matching the URL so that it can be referenced later.
* Because this is a Singleton object, any reference to this module can access all of the namespaces (by url).
* The namespace has some uib extensions that track the originating node id (searchable in Node-RED), the number of connected clients
* and the number of messages received.
* @param {uibNode} node Reference to the uibuilder node instance
*/
addNS(node) {
const log = this.log
const uib = this.uib
if (log === undefined) throw new Error('log is undefined')
if (uib === undefined) throw new Error('uib is undefined')
if (this.io === undefined) throw new Error('this.io is undefined')
const ioNs = this.ioNamespaces[node.url] = this.io.of(node.url)
// @ts-expect-error Add some additional metadata to NS
const url = ioNs.url = node.url
// @ts-expect-error Allows us to track back to the actual node in Node-RED
ioNs.nodeId = node.id
// @ts-expect-error ioNs.useSecurity = node.useSecurity // Is security on for this node instance?
ioNs.rcvMsgCount = 0
// @ts-expect-error Make Node-RED's log available to middleware via custom ns property
ioNs.log = log
// ioNs.clientLog = {}
if (uib.configFolder === null) throw new Error('uib.configFolder is undefined')
/** Check for <uibRoot>/.config/sioMiddleware.js, use it if present.
* Applies ONCE on a new client connection.
* Had to move to addNS since MW no longer globally loadable since sio v3
*/
const sioMwPath = join(uib.configFolder, 'sioMiddleware.js')
if ( existsSync(sioMwPath) ) { // not interested if the file doesn't exist
try {
const sioMiddleware = require(sioMwPath)
if ( typeof sioMiddleware === 'function' ) {
ioNs.use(sioMiddleware)
log.trace(`🌐[uibuilder[:socket:addNs:${url}] Socket.IO sioMiddleware.js middleware loaded successfully for NS.`)
} else {
log.warn(`🌐⚠️[uibuilder:socket:addNs:${url}] Socket.IO middleware failed to load for NS - check that uibRoot/.config/sioMiddleware.js has a valid exported fn.`)
}
} catch (e) {
log.warn(`🌐⚠️[uibuilder:socket:addNs:${url}] Socket.IO middleware failed to load for NS. Reason: ${e.message}`)
}
}
//#region -- trace room events --
// NB: Only sockets can join rooms, not the server
{ // eslint-disable-line no-lone-blocks
const thislog = log.trace
ioNs.adapter.on('create-room', (room) => {
thislog(
`[uibuilder:socket:addNS:${url}] Room "${room}" was created`
)
})
ioNs.adapter.on('delete-room', (room) => {
thislog(
`[uibuilder:socket:addNS:${url}] Room "${room}" was deleted`
)
})
ioNs.adapter.on('join-room', (room, id) => {
thislog(
`[uibuilder:socket:addNS:${url}] Socket ID "${id}" has joined room "${room}"`
)
})
ioNs.adapter.on('leave-room', (room, id) => {
thislog(
`[uibuilder:socket:addNS:${url}] Socket ID "${id}" has left room "${room}"`
)
})
}
//#endregion -- -- --
const that = this
// When a client connects to the server - create the socket listeners & do other stuff
ioNs.on('connection', (socket) => {
//#region ----- Event Handlers ----- //
// NOTE: as of sio v4, disconnect seems to be fired AFTER a connect when a client reconnects
socket.on('disconnect', (reason, description) => {
// ioNs.clientLog[socket.handshake.auth.clientId].connected = false
node.ioClientsCount = ioNs.sockets.size
log.trace(
`🌐[uibuilder:socket:${url}:disconnect] Client disconnected, clientCount: ${ioNs.sockets.size}, Reason: ${reason}, ID: ${socket.id}, IP Addr: ${getClientRealIpAddress(socket)}, Client ID: ${socket.handshake.auth.clientId}. For node ${node.id}`
)
node.statusDisplay.text = 'connected ' + ioNs.sockets.size
setNodeStatus( node )
// Let the control output port know a client has disconnected
const { _uib, _client } = this.getClientDetails(socket, node)
const ctrlMsg = {
...{
'uibuilderCtrl': 'client disconnect',
'reason': reason,
'topic': node.topic || undefined,
'from': 'server',
'description': description,
},
..._uib,
}
if (_client) ctrlMsg._client = _client
that.sendToFe(ctrlMsg, node, uib.ioChannels.control)
// Copy to port#2 for reference
that.sendCtrlMsg(ctrlMsg, node, 'addNS:disconnect')
// Let other nodes know a client is disconnecting (via custom event manager)
this.RED.events.emit(`uibuilder/${node.url}/clientDisconnect`, ctrlMsg)
}) // --- End of on-connection::on-disconnect() --- //
// Listen for msgs from clients on standard channel
socket.on(uib.ioChannels.client, function(msg) {
that.listenFromClientStd(msg, socket, node)
}) // --- End of on-connection::on-incoming-client-msg() --- //
// Listen for msgs from clients on control channel
socket.on(uib.ioChannels.control, function(msg) {
that.listenFromClientCtrl(msg, socket, node)
}) // --- End of on-connection::on-incoming-control-msg() --- //
// Listen for socket.io errors - output a control msg
socket.on('error', function(err) {
log.error(`🌐🛑[uibuilder:socket:addNs:${url}] ERROR received, ID: ${socket.id}, Reason: ${err.message}`)
// Let the control output port (port #2) know there has been an error
const { _uib, _client } = this.getClientDetails(socket, node)
const ctrlMsg = {
...{
uibuilderCtrl: 'socket error',
error: err.message,
from: 'server',
},
..._uib,
}
if (_client) ctrlMsg._client = _client
that.sendCtrlMsg(ctrlMsg, node, 'addNS:error')
}) // --- End of on-connection::on-error() --- //
// Custom room handling (clientId & pageId rooms are always joined)
// - NB: Clients don't understand rooms, they simply receive
// all messages send to all joined rooms.
// Messages have to include room name if need to differentiate at client.
// Server cannot listen to rooms but can send
// To send to a custom room from server: ioNs.to("project:4321").emit("project updated")
// Allow client to request to create/join a room
socket.on('uib-room-join', (room) => {
socket.join(room)
})
// Allow clients to request to leave a room
socket.on('uib-room-leave', (room) => {
socket.leave(room)
})
// Allow clients to send message to a custom room
socket.on('uib-room-send', (room, msg) => {
ioNs.to(room).emit(room, msg, socket.handshake.auth)
// TODO Option to send on as a node-red msg
})
//#endregion ----- Event Handlers ----- //
//#region ---- run when client connects ---- //
// How many client connections are there?
node.ioClientsCount = ioNs.sockets.size
log.trace(
`🌐[uibuilder:socket:addNS:${url}:connect] Client connected. ClientCount: ${ioNs.sockets.size}, Socket ID: ${socket.id}, IP Addr: ${getClientRealIpAddress(socket)}, Client ID: ${socket.handshake.auth.clientId}, Recovered?: ${socket.recovered}, Client Version: ${socket.handshake.auth.clientVersion}. For node ${node.id}`
)
if (uib.configFolder === null) throw new Error('uib.configFolder is undefined')
// Try to load the sioUse middleware function - sioUse applies to all incoming msgs
const mwfile = join(uib.configFolder, uib.sioUseMwName)
if ( existsSync(mwfile) ) { // not interested if the file doesn't exist
try {
const sioUseMw = require( mwfile )
if ( typeof sioUseMw === 'function' ) { // if exported, has to be a function
socket.use(sioUseMw)
log.trace(`🌐[uibuilder[:socket:onConnect:${url}] sioUse sioUse.js middleware loaded successfully for NS ${url}.`)
} else {
log.warn(`🌐⚠️[uibuilder:socket:onConnect:${url}] sioUse middleware failed to load for NS ${url} - check that uibRoot/.config/sioUse.js has a valid exported fn.`)
}
} catch (e) {
log.warn(`🌐⚠️[uibuilder:socket:addNS:${url}] sioUse failed to load Use middleware. Reason: ${e.message}`)
}
}
node.statusDisplay.text = `connected ${ioNs.sockets.size}`
setNodeStatus( node )
// Initial connect message to client
const msgClient = {
'uibuilderCtrl': 'client connect',
'serverTimestamp': (new Date()),
'topic': node.topic || undefined,
'version': uib.version, // Let the front-end know what v of uib is in use
'_socketId': socket.id,
// @ts-ignore
'maxHttpBufferSize': this.io.opts.maxHttpBufferSize || 1048576, // Let the client know the max msg size that can be sent, default=1MB
}
// msgClient.ip = getClientRealIpAddress(socket)
// msgClient.clientId = socket.handshake.auth.clientId
// msgClient.connections = socket.handshake.auth.connectedNum
// msgClient.pageName = socket.handshake.auth.pageName
// ioNs.clientLog[msg.clientId] = {
// ip: msg.ip,
// connections: msg.connections,
// connected: true,
// }
// Let the clients know we are connecting
that.sendToFe(msgClient, node, uib.ioChannels.control)
// Send initial client connect control msg (via port #2)
const { _uib, _client } = this.getClientDetails(socket, node)
const ctrlMsg = {
...{
uibuilderCtrl: 'client connect',
topic: node.topic || undefined,
from: 'server',
maxHttpBufferSize: msgClient.maxHttpBufferSize,
},
..._uib,
}
if (_client) ctrlMsg._client = _client
that.sendCtrlMsg(ctrlMsg, node, 'addNS:connection')
// Let other nodes know a client is connecting (via custom event manager)
this.RED.events.emit(`uibuilder/${node.url}/clientConnect`, ctrlMsg)
//#endregion ---- run when client connects ---- //
//#region ---- Rooms ----
// Ensures uib is listening to all clients and pages
socket.join(`clientId:${socket.handshake.auth.clientId}`)
socket.join(`pageName:${socket.handshake.auth.pageName}`)
// Not bothering with a tabId room - gets filtered at client anyway
// rooms for pathName not needed as each path has own namespace
//#endregion ---- ---- ----
}) // --- End of on-connection() --- //
} // --- End of addNS() --- //
/** Remove the current clients and namespace for this node.
* Called from uiblib.processClose.
* @param {uibNode} node Reference to the uibuilder node instance
*/
removeNS(node) {
const ioNs = this.ioNamespaces[node.url]
// Disconnect all connected sockets for this Namespace (Socket.io v4+)
ioNs.disconnectSockets(true)
ioNs.removeAllListeners() // Remove all Listeners for the event emitter
// No longer works from socket.io v3+ //delete this.io.nsps[`/${node.url}`] // Remove from the server namespaces
} // --- End of removeNS() --- //
} // ==== End of UibSockets Class Definition ==== //
/** Singleton model. Only 1 instance of UibSockets should ever exist.
* Use as: `const sockets = require('./libs/socket.js')`
* Wrap in try/catch to force out better error logging if there is a problem
* Downside of this approach is that you cannot directly pass in parameters. Use the startup(...) method instead.
*/
try { // Wrap in a try in case any errors creep into the class
const uibsockets = new UibSockets()
module.exports = uibsockets
} catch (e) {
console.error(`[uibuilder:socket.js] Unable to create class instance. Error: ${e.message}`)
}
// EOF