UNPKG

node-red-contrib-dynamic-websocket

Version:

A node that dynamically connects to a WebSocket URL, can send and receive messages, and reports connection state changes.

438 lines (391 loc) 18.9 kB
module.exports = function(RED) { function DynamicWebSocketNode(config) { RED.nodes.createNode(this, config); var node = this; var WebSocket = require('ws'); var ws = null; var reconnectTimeout = null; var reconnectAttempts = 0; // Load the stored URL from persistent storage node.url = node.context().get('storedUrl') || config.url || ""; node.allowSelfSigned = config.allowSelfSigned || false; // Reconnection settings node.autoReconnect = config.autoReconnect || false; node.reconnectAttempts = config.reconnectAttempts || 0; // 0 = unlimited node.reconnectInterval = config.reconnectInterval || 5000; // Default: 5 seconds node.useExponentialBackoff = config.useExponentialBackoff || false; // Authentication settings node.authType = config.authType || 'none'; node.username = config.username || ''; node.password = config.password || ''; node.token = config.token || ''; node.tokenLocation = config.tokenLocation || 'header'; node.tokenKey = config.tokenKey || 'Authorization'; // Custom headers - parse from JSON if provided try { node.headers = config.headers ? JSON.parse(config.headers) : {}; } catch (e) { node.warn("Invalid headers JSON: " + e.message); node.headers = {}; } // Message transformation settings node.transformMessages = config.transformMessages || false; node.messageFormat = config.messageFormat || 'json'; node.binarySupport = config.binarySupport || false; node.validateMessages = config.validateMessages || false; // Message template - parse from JSON if provided try { node.messageTemplate = config.messageTemplate ? JSON.parse(config.messageTemplate) : {}; } catch (e) { node.warn("Invalid message template JSON: " + e.message); node.messageTemplate = {}; } function calculateReconnectDelay() { // Calculate delay with exponential backoff if enabled if (node.useExponentialBackoff) { // Cap the exponent to avoid extremely long delays const exponent = Math.min(reconnectAttempts, 10); // Base delay * 2^attempt with some randomization to avoid thundering herd return Math.floor(node.reconnectInterval * Math.pow(1.5, exponent) * (0.8 + Math.random() * 0.4)); } else { return node.reconnectInterval; } } function scheduleReconnect() { // Clear any existing reconnect timeout if (reconnectTimeout) { clearTimeout(reconnectTimeout); reconnectTimeout = null; } // Check if we've exceeded the maximum number of attempts if (node.reconnectAttempts > 0 && reconnectAttempts >= node.reconnectAttempts) { node.status({fill:"red", shape:"dot", text:"reconnect failed after " + reconnectAttempts + " attempts"}); node.send([null, {state: "reconnect_failed", attempts: reconnectAttempts}, null]); reconnectAttempts = 0; return; } // Calculate delay with exponential backoff if enabled const delay = calculateReconnectDelay(); node.status({fill:"yellow", shape:"ring", text:"reconnecting in " + Math.floor(delay/1000) + "s (attempt " + (reconnectAttempts + 1) + ")"}); reconnectTimeout = setTimeout(function() { reconnectAttempts++; node.status({fill:"yellow", shape:"ring", text:"reconnecting... attempt " + reconnectAttempts}); node.send([null, {state: "reconnecting", attempt: reconnectAttempts}, null]); connectWebSocket(node.url); }, delay); } function connectWebSocket(url) { // Clear any existing reconnect timeout if (reconnectTimeout) { clearTimeout(reconnectTimeout); reconnectTimeout = null; } if (!url) { node.status({fill:"yellow", shape:"ring", text:"No URL set"}); return; } node.log("Attempting to connect to WebSocket URL: " + url); node.url = url; // Store the URL in persistent storage node.context().set('storedUrl', url); // Create WebSocket with options for self-signed certificates and authentication const wsOptions = {}; const headers = {}; // Handle self-signed certificates if (node.allowSelfSigned) { wsOptions.rejectUnauthorized = false; } // Handle authentication if (node.authType === 'basic') { // Basic authentication const auth = 'Basic ' + Buffer.from(node.username + ':' + node.password).toString('base64'); headers['Authorization'] = auth; } else if (node.authType === 'token') { // Token-based authentication if (node.tokenLocation === 'header') { // Add token to headers const tokenValue = node.tokenKey.toLowerCase() === 'authorization' && !node.token.startsWith('Bearer ') ? 'Bearer ' + node.token : node.token; headers[node.tokenKey] = tokenValue; } else if (node.tokenLocation === 'url' && url.indexOf('?') === -1) { // Add token to URL as query parameter url = url + '?' + encodeURIComponent(node.tokenKey) + '=' + encodeURIComponent(node.token); } else if (node.tokenLocation === 'url') { // Add token to existing URL query parameters url = url + '&' + encodeURIComponent(node.tokenKey) + '=' + encodeURIComponent(node.token); } } // Add custom headers if (node.headers && typeof node.headers === 'object') { Object.assign(headers, node.headers); } // Set headers in options if (Object.keys(headers).length > 0) { wsOptions.headers = headers; } node.log("Creating WebSocket with options: " + JSON.stringify(wsOptions)); ws = new WebSocket(url, wsOptions); ws.on('open', function() { node.log("WebSocket connection opened successfully to: " + url); node.status({fill:"green", shape:"dot", text:url}); node.send([null, null, {state: "Connected"}]); }); ws.on('close', function(code) { node.status({fill:"red", shape:"ring", text:"disconnected"}); // Only send state message if it wasn't closed by msg.close if (code !== 1000) { node.send([null, {state: "disconnected", code: code}, null]); // Auto reconnect if enabled and it wasn't a normal closure if (node.autoReconnect && code !== 1000 && node.url) { scheduleReconnect(); } } else { // Reset reconnect attempts on normal closure reconnectAttempts = 0; } }); ws.on('error', function(error) { node.status({fill:"red", shape:"dot", text:"error"}); node.error("WebSocket error: " + error); node.send([null, {state: "error", error: error.toString()}, null]); // The 'close' event will be triggered after the error event // Auto-reconnect will be handled there }); ws.on('message', function(data) { let payload; // Handle binary data if enabled if (node.binarySupport && data instanceof Buffer) { payload = { binary: true, data: data, length: data.length }; } else { // Try to parse as JSON if not binary or binary not enabled try { payload = JSON.parse(data); } catch (e) { payload = data; } } node.send([{payload: payload}, null, null]); }); } // Connect on startup if URL is set if (node.url) { connectWebSocket(node.url); } node.on('input', function(msg, send, done) { if (msg.url) { node.log("Received msg.url: " + msg.url); // Allow dynamic override of self-signed certificate option if (msg.allowSelfSigned !== undefined) { node.allowSelfSigned = msg.allowSelfSigned; } // Allow dynamic override of reconnection settings if (msg.autoReconnect !== undefined) { node.autoReconnect = msg.autoReconnect; } if (msg.reconnectAttempts !== undefined) { node.reconnectAttempts = msg.reconnectAttempts; } if (msg.reconnectInterval !== undefined) { node.reconnectInterval = msg.reconnectInterval; } if (msg.useExponentialBackoff !== undefined) { node.useExponentialBackoff = msg.useExponentialBackoff; } // Allow dynamic override of authentication settings if (msg.authType !== undefined) { node.authType = msg.authType; } if (msg.username !== undefined) { node.username = msg.username; } if (msg.password !== undefined) { node.password = msg.password; } if (msg.token !== undefined) { node.token = msg.token; } if (msg.tokenLocation !== undefined) { node.tokenLocation = msg.tokenLocation; } if (msg.tokenKey !== undefined) { node.tokenKey = msg.tokenKey; } if (msg.headers !== undefined) { if (typeof msg.headers === 'object') { node.headers = msg.headers; } else if (typeof msg.headers === 'string') { try { node.headers = JSON.parse(msg.headers); } catch (e) { node.warn("Invalid headers JSON in message: " + e.message); } } } // Allow dynamic override of message transformation settings if (msg.transformMessages !== undefined) { node.transformMessages = msg.transformMessages; } if (msg.messageFormat !== undefined) { node.messageFormat = msg.messageFormat; } if (msg.binarySupport !== undefined) { node.binarySupport = msg.binarySupport; } if (msg.validateMessages !== undefined) { node.validateMessages = msg.validateMessages; } if (msg.messageTemplate !== undefined) { if (typeof msg.messageTemplate === 'object') { node.messageTemplate = msg.messageTemplate; } else if (typeof msg.messageTemplate === 'string') { try { node.messageTemplate = JSON.parse(msg.messageTemplate); } catch (e) { node.warn("Invalid message template JSON in message: " + e.message); } } } // Update the node URL and store it persistently before connecting node.url = msg.url; node.context().set('storedUrl', msg.url); // Reset reconnect attempts when connecting to a new URL reconnectAttempts = 0; connectWebSocket(msg.url); } else if (msg.reconnect === true) { // Force a reconnection if we have a URL if (node.url) { reconnectAttempts = 0; connectWebSocket(node.url); } } else if (msg.close === true) { // Clear any reconnection timeout if (reconnectTimeout) { clearTimeout(reconnectTimeout); reconnectTimeout = null; } if (ws) { ws.close(1000); // Use 1000 to indicate normal closure } node.url = ""; node.context().set('storedUrl', ""); reconnectAttempts = 0; node.status({fill:"yellow", shape:"ring", text:"closed"}); } else if (msg.message) { if (ws && ws.readyState === WebSocket.OPEN) { // Handle message transformation if enabled if (node.transformMessages && !msg.skipTransform) { let transformedMessage = transformMessage(msg.message); if (transformedMessage !== null) { // Handle binary data if enabled if (node.binarySupport && msg.binary === true && Buffer.isBuffer(transformedMessage)) { ws.send(transformedMessage); } else { ws.send(JSON.stringify(transformedMessage)); } } } else { // Handle binary data if enabled if (node.binarySupport && msg.binary === true && Buffer.isBuffer(msg.message)) { ws.send(msg.message); } else { ws.send(JSON.stringify(msg.message)); } } } else { node.warn("WebSocket is not open. Cannot send message."); } } done(); }); node.on('close', function(done) { // Clear any reconnection timeout if (reconnectTimeout) { clearTimeout(reconnectTimeout); reconnectTimeout = null; } if (ws) { ws.close(); } done(); }); } // Function to transform messages based on selected format and template function transformMessage(message) { try { // Skip transformation for null or undefined messages if (message === null || message === undefined) { return null; } // Apply template if available if (Object.keys(node.messageTemplate).length > 0) { let result = JSON.parse(JSON.stringify(node.messageTemplate)); // Clone template // Simple placeholder replacement for string values function replaceValues(obj, data) { for (let key in obj) { if (typeof obj[key] === 'string' && obj[key].startsWith('$')) { const placeholder = obj[key].substring(1); // Remove $ prefix if (data[placeholder] !== undefined) { obj[key] = data[placeholder]; } } else if (typeof obj[key] === 'object' && obj[key] !== null) { replaceValues(obj[key], data); } } return obj; } result = replaceValues(result, message); // Validate message if validation is enabled if (node.validateMessages) { // Implement validation logic based on message format // For now, just check if required fields are present let valid = true; for (let key in result) { if (result[key] === undefined || result[key] === null) { valid = false; node.warn("Message validation failed: Missing required field '" + key + "'"); break; } } if (!valid) { return null; } } return result; } else { // No template, just return the original message return message; } } catch (e) { node.warn("Message transformation failed: " + e.message); return null; } } RED.nodes.registerType("dynamic-websocket", DynamicWebSocketNode, { defaults: { name: {value: ""}, url: {value: ""}, allowSelfSigned: {value: false}, autoReconnect: {value: false}, reconnectAttempts: {value: 0}, reconnectInterval: {value: 5000}, useExponentialBackoff: {value: false}, authType: {value: "none"}, username: {value: ""}, password: {value: "", type: "password"}, token: {value: "", type: "password"}, tokenLocation: {value: "header"}, tokenKey: {value: "Authorization"}, headers: {value: ""}, transformMessages: {value: false}, messageFormat: {value: "json"}, binarySupport: {value: false}, validateMessages: {value: false}, messageTemplate: {value: ""} } }); }