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.
390 lines (328 loc) • 18.5 kB
JavaScript
/* eslint-disable max-params */
/** 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-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.
*/
const socketio = require('socket.io')
const tilib = require('./tilib') // General purpose library (by Totally Information)
const uiblib = require('./uiblib') // Utility library for uibuilder
const path = require('path')
class UibSockets {
/** 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
/** 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 {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 ) {
if ( RED ) this.RED = RED
if ( uib ) this.uib = uib
if ( uib ) this.log = log
if ( uib ) this.server = server
this.socketIoSetup()
} // --- End of setup() --- //
/** Output a control msg to the front-end
* Sends to all connected clients & outputs a msg to port 2 if required
* @param {Object} msg The message to output
* @param {Object} node The node object
* @param {string=} socketId Optional. If included, only send to specific client id
* @param {boolean=} output Optional. If included, also output to port #2 of the node @since 2020-01-03
*/
sendControl( msg, node, socketId, output) {
/** @type {Object} Reference to the core uibuilder config object */
const uib = this.uib
const ioNs = this.ioNamespaces[node.url]
if (output === undefined || output === null) output = true
msg.from = 'server'
if (socketId) msg._socketId = socketId
// Send to specific client if required
if (msg._socketId) ioNs.to(msg._socketId).emit(uib.ioChannels.control, msg)
else ioNs.emit(uib.ioChannels.control, msg)
if ( (! Object.prototype.hasOwnProperty.call(msg, 'topic')) && (node.topic !== '') ) msg.topic = node.topic
// copy msg to output port #2 if required
if ( output === true ) node.send([null, msg])
} // ---- End of getProps ---- //
/** Output a normal msg to the front-end
* @param {Object} msg The message to output
* @param {Object} url The uibuilder instance url - will be unique. Used to lookup the correct Socket.IO namespace for sending.
* @param {string=} socketId Optional. If included, only send to specific client id (mostly expecting this to be on msg._socketID so not often required)
*/
send(msg, url, socketId) { // eslint-disable-line class-methods-use-this
const uib = this.uib
const ioNs = this.ioNamespaces[url]
if (socketId) msg._socketId = socketId
// TODO: This should have some safety validation on it!
if (msg._socketId) {
//! TODO If security is active ...
// ...If socketId not validated as having a current session, don't send
this.log.trace(`[uibuilder:socket.js:send:${url}] msg sent on to client ${msg._socketId}. Channel: ${uib.ioChannels.server}`, msg)
ioNs.to(msg._socketId).emit(uib.ioChannels.server, msg)
} else {
//? - is there any way to prevent sending to clients not logged in?
this.log.trace(`[uibuilder:socket.js:send:${url}] msg sent on to ALL clients. Channel: ${uib.ioChannels.server}`, msg)
ioNs.emit(uib.ioChannels.server, msg)
}
}
socketIoSetup() {
//#region ----- Set up Socket.IO server & middleware ----- //
/** 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.
**/
// Reference static vars
const uib = this.uib
//const RED = this.RED
const log = this.log
const server = this.server
const uib_socketPath = this.uib_socketPath = tilib.urlJoin(uib.nodeRoot, uib.moduleName, 'vendor', 'socket.io')
log.trace('[uibuilder:Module] Socket.IO initialisation - Socket Path=', uib_socketPath )
let ioOptions = {
'path': uib_socketPath,
// for CORS need to handle preflight request explicitly 'cause there's an
// Allow-Headers:X-ClientId in there. see https://socket.io/docs/v2/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()
},
}
const io = this.io = socketio.listen(server, ioOptions) // listen === attach
// @ts-expect-error ts(2339)
io.set('transports', ['polling', 'websocket'])
/** Check for <uibRoot>/.config/sioMiddleware.js, use it if present. Copy template if not exists @since v2.0.0-dev3 */
let sioMwPath = path.join(uib.configFolder, 'sioMiddleware.js')
try {
const sioMiddleware = require(sioMwPath)
if ( typeof sioMiddleware === 'function' ) {
io.use(require(sioMwPath))
}
} catch (e) {
log.trace('[uibuilder:Module] Socket.IO Middleware failed to load. Reason: ', e.message)
}
} // --- End of socketIoSetup() --- //
/** Add a new Socket.IO NAMESPACE
* 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.
* @param {uibNode} node Reference to the uibuilder node instance
* @return {socketio.Namespace} Return a reference to the namespace for convenience in core code
*/
addNS(node) {
const log = this.log
const uib = this.uib
const ioNs = this.ioNamespaces[node.url] = this.io.of(node.url)
const url = ioNs.url = node.url
ioNs.nodeId = node.id // allows us to track back to the actual node in Node-RED
ioNs.ioClientsCount = 0
ioNs.rcvMsgCount = 0
const that = this
ioNs.on('connection', function(socket) {
ioNs.ioClientsCount++
log.trace(`[uibuilder:socket.js:addNS:${url}] Socket connected for node ${node.id} clientCount: ${ioNs.ioClientsCount}, Socket ID: ${socket.id}`)
// Try to load the sioUse middleware function
try {
const sioUseMw = require( path.join(uib.configFolder, uib.sioUseMwName) )
if ( typeof sioUseMw === 'function' ) socket.use(sioUseMw)
} catch(e) {
log.trace(`[uibuilder:socket.js:addNS:${url}] Socket.use Failed to load Use middleware. Reason: `, e.message)
}
uiblib.setNodeStatus( { fill: 'green', shape: 'dot', text: 'connected ' + ioNs.ioClientsCount }, node )
// Let the clients (and output #2) know we are connecting
that.sendControl({
'uibuilderCtrl': 'client connect',
'cacheControl': 'REPLAY', // @since 2017-11-05 v0.4.9 @see WIKI for details
// @since 2018-10-07 v1.0.9 - send server timestamp so that client can work out
// time difference (UTC->Local) without needing clever libraries.
'serverTimestamp': (new Date()),
topic: node.topic || undefined,
}, node, socket.id, true)
//ioNs.emit( uib.ioChannels.control, { 'uibuilderCtrl': 'server connected', 'debug': node.debugFE } )
// Listen for msgs from clients only on specific input channels:
socket.on(uib.ioChannels.client, function(msg) {
log.trace(`[uibuilder:${url}] Data received from client, ID: ${socket.id}, Msg:`, 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
// If security is active...
if (node.useSecurity === true) {
/** Check for valid auth and session
* @type MsgAuth */
msg._auth = uiblib.authCheck(msg, ioNs, node, socket, log, uib)
}
// Send out the message for downstream flows
// TODO: This should probably have safety validations!
node.send(msg)
}) // --- End of on-connection::on-incoming-client-msg() --- //
socket.on(uib.ioChannels.control, function(msg) {
log.trace(`[uibuilder:${url}] Control Msg from client, ID: ${socket.id}, Msg:`, 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 }
}
// If the sender hasn't added Socket.id, add it now
if ( ! Object.prototype.hasOwnProperty.call(msg, '_socketId') ) msg._socketId = socket.id
// @since 2017-11-05 v0.4.9 If the sender hasn't added msg.from, add it now
if ( ! Object.prototype.hasOwnProperty.call(msg, 'from') ) msg.from = 'client'
/** If a logon/logoff msg, we need to process it directly (don't send on the msg in this case) */
if ( msg.uibuilderCtrl === 'logon') {
uiblib.logon(msg, ioNs, node, socket, log, uib)
} else if ( msg.uibuilderCtrl === 'logoff') {
uiblib.logoff(msg, ioNs, node, socket, log)
} else {
// If security is active...
if (node.useSecurity === true) {
/** Check for valid auth and session
* @type MsgAuth */
msg._auth = uiblib.authCheck(msg, ioNs, node, socket, log, uib)
}
// Send out the message on port #2 for downstream flows
if ( ! msg.topic ) msg.topic = node.topic
node.send([null,msg])
}
}) // --- End of on-connection::on-incoming-control-msg() --- //
socket.on('disconnect', function(reason) {
ioNs.ioClientsCount--
log.trace(
`[uibuilder:${url}] Socket disconnected, clientCount: ${ioNs.ioClientsCount}, Reason: ${reason}, ID: ${socket.id}`
)
if ( ioNs.ioClientsCount <= 0) uiblib.setNodeStatus( { fill: 'blue', shape: 'dot', text: 'connected ' + ioNs.ioClientsCount }, node )
else uiblib.setNodeStatus( { fill: 'green', shape: 'ring', text: 'connected ' + ioNs.ioClientsCount }, node )
// Let the control output port know a client has disconnected
that.sendControl({
'uibuilderCtrl': 'client disconnect',
'reason': reason,
topic: node.topic || undefined,
}, node, socket.id, true)
//node.send([null, {'uibuilderCtrl': 'client disconnect', '_socketId': socket.id, 'topic': node.topic}])
}) // --- End of on-connection::on-disconnect() --- //
socket.on('error', function(err) {
log.error(`[uibuilder:${url}] ERROR received, ID: ${socket.id}, Reason: ${err.message}`)
// Let the control output port know there has been an error
that.sendControl({
'uibuilderCtrl': 'socket error',
'error': err.message,
topic: node.topic || undefined,
}, node, socket.id, true)
}) // --- End of on-connection::on-error() --- //
/* More Socket.IO events but we really don't need to monitor them
socket.on('disconnecting', function(reason) {
RED.log.audit({
'UIbuilder': node.url+' DISCONNECTING received', 'ID': socket.id,
'data': reason
})
})
socket.on('newListener', function(data) {
RED.log.audit({
'UIbuilder': node.url+' NEWLISTENER received', 'ID': socket.id,
'data': data
})
})
socket.on('removeListener', function(data) {
RED.log.audit({
'UIbuilder': node.url+' REMOVELISTENER received', 'ID': socket.id,
'data': data
})
})
// ping is received every 30 sec
socket.on('ping', function(data) {
RED.log.audit({
'UIbuilder': node.url+' PING received', 'ID': socket.id,
'data': data
})
})
socket.on('pong', function(data) {
RED.log.audit({
'UIbuilder': node.url+' PONG received', 'ID': socket.id,
'data': data
})
})
*/
}) // --- End of addNS() --- //
return ioNs
} // --- End of addNS() --- //
/** Remove the current clients and namespace for this node.
* Called from uiblib.processClose.
*/
removeNS(node) {
const ioNs = this.ioNamespaces[node.url]
// Disconnect all Socket.IO clients from this NS
const connectedNameSpaceSockets = Object.keys(ioNs.connected) // Get Object with Connected SocketIds as properties
if ( connectedNameSpaceSockets.length >0 ) {
connectedNameSpaceSockets.forEach(socketId => {
ioNs.connected[socketId].disconnect() // Disconnect Each socket
})
}
ioNs.removeAllListeners() // Remove all Listeners for the event emitter
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('./socket.js')`
*/
module.exports = new UibSockets()
// EOF