UNPKG

node-red-contrib-redis-variable

Version:

A comprehensive Node-RED node for Redis operations with universal payload-based configuration, automatic JSON handling, SSL/TLS support, and advanced pattern matching with pagination

500 lines (437 loc) 20.1 kB
const Redis = require("ioredis"); module.exports = function (RED) { let connections = {}; let usedConn = {}; function getValueFromContext(node, value, type, msg) { if (value === null || value === undefined) return null; try { let result; switch (type) { case 'flow': const flowContext = node.context().flow; if (!flowContext) { return null; } result = getNestedValue(flowContext, value); break; case 'global': const globalContext = node.context().global; if (!globalContext) { return null; } result = getNestedValue(globalContext, value); break; case 'env': result = process.env[value]; break; case 'msg': result = RED.util.getMessageProperty(msg, value); break; default: result = value; } return result !== undefined ? result : null; } catch (err) { throw new Error(`Failed to get value for type: ${type}, value: ${value}. Error: ${err.message}`); } } // Helper function to get nested values like "redis_config.host" function getNestedValue(context, path) { if (!context) return undefined; if (path.includes('.')) { const parts = path.split('.'); let result = context.get(parts[0]); // If the first part returns an object, traverse it if (result && typeof result === 'object') { for (let i = 1; i < parts.length; i++) { if (result && typeof result === 'object' && result[parts[i]] !== undefined) { result = result[parts[i]]; } else { return undefined; } } return result; } else { // If first part is not an object, try to get the full path as a single value return context.get(path); } } else { return context.get(path); } } function RedisConfigNode(config) { RED.nodes.createNode(this, config); this.name = config.name || "Redis Config"; this.cluster = config.cluster || false; // Connection configuration this.hostType = config.hostType || 'str'; this.host = config.host || 'localhost'; this.hostContext = config.hostContext; this.port = config.port || 6379; this.portType = config.portType || 'str'; this.portContext = config.portContext; // Authentication this.passwordType = config.passwordType || 'str'; this.password = config.password; this.passwordContext = config.passwordContext; this.username = config.username; this.usernameType = config.usernameType || 'str'; this.usernameContext = config.usernameContext; // SSL/TLS Configuration this.enableTLS = config.enableTLS || false; this.tlsRejectUnauthorized = config.tlsRejectUnauthorized !== false; // Default to true this.tlsCertType = config.tlsCertType || 'str'; this.tlsCertContext = config.tlsCertContext; this.tlsKeyType = config.tlsKeyType || 'str'; this.tlsKeyContext = config.tlsKeyContext; this.tlsCaType = config.tlsCaType || 'str'; this.tlsCaContext = config.tlsCaContext; // Database and other options this.database = config.database || 0; this.databaseType = config.databaseType || 'str'; this.databaseContext = config.databaseContext; // Advanced options this.optionsType = config.optionsType || 'json'; this.options = config.options || '{}'; this.optionsContext = config.optionsContext; // Get credentials for string passwords const credentials = this.credentials || {}; // Helper method to parse credential values this.parseCredentialValue = function(value, type, msg, executingNode) { if (!value && value !== 0) { return null; } try { let result; switch (type) { case 'str': result = value; break; case 'flow': result = getValueFromContext(executingNode || this, value, 'flow', msg); break; case 'global': result = getValueFromContext(executingNode || this, value, 'global', msg); if (executingNode && process.env.NODE_RED_DEBUG) { executingNode.log(`Context lookup - Type: global, Path: ${value}, Result: ${result}`); } break; case 'env': result = process.env[value] || null; break; case 'json': try { result = JSON.parse(value); } catch (e) { result = value; } break; case 'num': result = Number(value); break; default: result = value; } return result; } catch (error) { if (executingNode) { executingNode.error(`Error parsing credential value: ${error.message}`); } return null; } }; // Check if configuration exists in context this.hasValidConfiguration = function(msg, executingNode) { try { // If using string configuration for host and port, it's always valid if (this.hostType === 'str' && this.portType === 'str') { return true; } // For context-based configuration, check if the required values exist let hasHost = false; let hasPort = false; // Check host configuration if (this.hostType === 'str') { hasHost = !!(this.host); } else { const contextHost = this.parseCredentialValue(this.hostContext, this.hostType, msg, executingNode); hasHost = !!(contextHost); } // Check port configuration if (this.portType === 'str') { hasPort = !!(this.port); } else { const contextPort = this.parseCredentialValue(this.portContext, this.portType, msg, executingNode); hasPort = !!(contextPort); } // We need at least host and port to have a valid configuration const result = hasHost && hasPort; if (executingNode && process.env.NODE_RED_DEBUG) { executingNode.log(`Configuration check - hasHost: ${hasHost}, hasPort: ${hasPort}, result: ${result}`); } return result; } catch (error) { if (executingNode) { executingNode.error(`Error checking configuration: ${error.message}`); } return false; } }; // Get Redis connection options this.getConnectionOptions = function(msg, executingNode) { try { // Parse host - handle both typedInput and direct context let host; if (this.hostType === 'str') { host = this.host || 'localhost'; } else { host = this.parseCredentialValue(this.hostContext, this.hostType, msg, executingNode) || 'localhost'; } // Parse port - handle both typedInput and direct context let port; if (this.portType === 'str') { port = this.port || 6379; } else { port = this.parseCredentialValue(this.portContext, this.portType, msg, executingNode) || 6379; } // Parse database - handle both typedInput and direct context let database; if (this.databaseType === 'str') { database = this.database || 0; } else { database = this.parseCredentialValue(this.databaseContext, this.databaseType, msg, executingNode) || 0; } // Parse password let password = null; if (this.passwordType === 'str') { password = this.credentials?.password; } else { password = this.parseCredentialValue(this.passwordContext, this.passwordType, msg, executingNode); } // Parse username let username = null; if (this.usernameType === 'str') { username = this.credentials?.username; } else { username = this.parseCredentialValue(this.usernameContext, this.usernameType, msg, executingNode); } // Parse additional options let additionalOptions = {}; if (this.optionsType === 'json') { try { additionalOptions = JSON.parse(this.options || '{}'); } catch (e) { additionalOptions = {}; } } else { additionalOptions = this.parseCredentialValue(this.optionsContext, this.optionsType, msg, executingNode) || {}; } // Build connection options const connectionOptions = { host: host, port: parseInt(port), db: parseInt(database), retryDelayOnFailover: 100, enableReadyCheck: false, maxRetriesPerRequest: null, ...additionalOptions }; // Add authentication if provided if (password) { connectionOptions.password = password; } if (username) { connectionOptions.username = username; } // Add SSL/TLS configuration if enabled if (this.enableTLS) { connectionOptions.tls = { rejectUnauthorized: this.tlsRejectUnauthorized }; // Parse TLS Certificate let tlsCert = null; if (this.tlsCertType === 'str') { tlsCert = this.credentials?.tlsCert; } else { tlsCert = this.parseCredentialValue(this.tlsCertContext, this.tlsCertType, msg, executingNode); } // Parse TLS Key let tlsKey = null; if (this.tlsKeyType === 'str') { tlsKey = this.credentials?.tlsKey; } else { tlsKey = this.parseCredentialValue(this.tlsKeyContext, this.tlsKeyType, msg, executingNode); } // Parse TLS CA let tlsCa = null; if (this.tlsCaType === 'str') { tlsCa = this.credentials?.tlsCa; } else { tlsCa = this.parseCredentialValue(this.tlsCaContext, this.tlsCaType, msg, executingNode); } // Add TLS options if provided if (tlsCert && tlsKey) { connectionOptions.tls.cert = tlsCert; connectionOptions.tls.key = tlsKey; } if (tlsCa) { connectionOptions.tls.ca = tlsCa; } // If using custom CA or self-signed certificates, might need to disable rejection if (!this.tlsRejectUnauthorized) { connectionOptions.tls.rejectUnauthorized = false; } } else { // Explicitly disable TLS for non-SSL connections connectionOptions.tls = false; } return connectionOptions; } catch (error) { if (executingNode) { executingNode.error(`Failed to get Redis connection options: ${error.message}`); } throw error; } }; // Get Redis client this.getClient = function(msg, executingNode, nodeId) { try { const id = nodeId || this.id; // Return existing connection if available if (connections[id]) { usedConn[id]++; return connections[id]; } // Check if configuration is available before attempting connection if (!this.hasValidConfiguration(msg, executingNode)) { if (executingNode) { // Only warn once per node to avoid spam if (!executingNode._configWarningShown) { executingNode.warn("Redis configuration not available in context. Skipping connection attempt."); executingNode._configWarningShown = true; // Reset warning flag after 30 seconds setTimeout(() => { if (executingNode) { executingNode._configWarningShown = false; } }, 30000); } } return null; } const options = this.getConnectionOptions(msg, executingNode); // Add connection limits to prevent infinite retry loops const connectionOptions = { ...options, maxRetriesPerRequest: 1, // Limit retries per request retryDelayOnFailover: 100, enableReadyCheck: false, lazyConnect: true, // Don't connect immediately connectTimeout: 5000, // 5 second timeout commandTimeout: 3000, // 3 second command timeout // Disable automatic reconnection to prevent error loops retryDelayOnClusterDown: 0, retryDelayOnFailover: 0, maxRetriesPerRequest: 0 }; // Create Redis client let client; if (this.cluster) { // For cluster mode, options should be an array of nodes const clusterNodes = Array.isArray(connectionOptions) ? connectionOptions : [connectionOptions]; client = new Redis.Cluster(clusterNodes); } else { client = new Redis(connectionOptions); } // Track error state to prevent spam let errorReported = false; let lastErrorTime = 0; const ERROR_REPORT_INTERVAL = 30000; // Report errors only once per 30 seconds // Handle connection errors client.on("error", (e) => { const now = Date.now(); // Only report errors once per interval to prevent spam if (!errorReported || (now - lastErrorTime) > ERROR_REPORT_INTERVAL) { let errorMsg = `Redis connection error: ${e.message}`; // Add specific diagnostics for common SSL issues if (e.message.includes("Protocol error") || e.message.includes("\\u0015")) { errorMsg += "\nThis usually indicates an SSL/TLS configuration issue. Try:"; errorMsg += "\n1. Disable SSL/TLS if your Redis server doesn't support it"; errorMsg += "\n2. Use port 6380 for Redis SSL instead of 6379"; errorMsg += "\n3. Check if your Redis server is configured for SSL"; errorMsg += "\n4. Enable 'Debug: Force No SSL' option for testing"; } if (executingNode) { executingNode.error(errorMsg, {}); } else { this.error(errorMsg, {}); } errorReported = true; lastErrorTime = now; } }); // Handle successful connection client.on("connect", () => { errorReported = false; if (executingNode) { executingNode.status({ fill: "green", shape: "dot", text: "connected" }); } }); // Handle disconnection client.on("disconnect", () => { if (executingNode) { executingNode.status({ fill: "red", shape: "ring", text: "disconnected" }); } }); // Store connection connections[id] = client; if (usedConn[id] === undefined) { usedConn[id] = 1; } else { usedConn[id]++; } return client; } catch (error) { if (executingNode) { executingNode.error(`Failed to create Redis client: ${error.message}`); } throw error; } }; // Disconnect method this.disconnect = function(nodeId) { const id = nodeId || this.id; if (usedConn[id] !== undefined) { usedConn[id]--; } if (connections[id] && usedConn[id] <= 0) { connections[id].disconnect(); delete connections[id]; delete usedConn[id]; } }; // Force disconnect method for error recovery this.forceDisconnect = function(nodeId) { const id = nodeId || this.id; if (connections[id]) { try { connections[id].disconnect(); } catch (e) { // Ignore disconnect errors } delete connections[id]; delete usedConn[id]; } }; // Clean up on node close this.on('close', function() { this.disconnect(); }); } RED.nodes.registerType("redis-variable-config", RedisConfigNode, { credentials: { password: { type: "password" }, username: { type: "text" } } }); };