UNPKG

websocket-cross-server-adapter

Version:

A Node.js based WebSocket distributed communication framework that enables seamless multi-server collaboration for real-time communication, cross-server event handling, and scalable applications.

1,045 lines (911 loc) 105 kB
/** * Copyright (c) 2025 LiuYiSong * Email: 349233775@qq.com * https://github.com/LiuYiSong/websocket-cross-server-adapter * All rights reserved. * * WebSocketCrossServerAdapter Class * * A communication adapter designed for distributed systems, supporting WebSocket services and cross-server event broadcasting, message delivery, and room management. * Suitable for game servers, real-time applications, microservice communication, etc., helping to build a high-performance, loosely coupled, and scalable distributed architecture. * */ 'use strict'; const debug = require("debug")("WSCSA"); const notepack = require('notepack.io'); const Redis = require('ioredis'); const WebSocket = require('ws'); class WebSocketCrossServerAdapter { /** * WebSocketCrossServerAdapter constructor * * @param {Object} options - Configuration options * @param {string} [options.serverName] - A **globally unique identifier** for the current server node. * This value is **required** for correct cross-server communication. * It must be consistent and unique across all running servers, such as `"us-east-1-node3"` or `"game-server-42"`. * Do **NOT** use `process.pid` or other machine-local identifiers, as they may collide in a distributed setup. * @param {string} [options.bridgePrefix='csbp:'] - Cross-server bridge prefix * @param {string} [options.wsPrefix='ws:'] - WebSocket channel prefix * @param {Object} [options.wsOptions] - Full WebSocket.Server configuration. * This object will be passed directly into the `ws` library's constructor. * See: https://github.com/websockets/ws?tab=readme-ov-file#server-options * @param {number} [options.serverPingInterval=20000] - WebSocket ping interval * @param {number} [options.enterBackgroundCloseTime=10000] - Close delay after background * @param {string} [options.heartbeatStr=''] - Heartbeat string * @param {boolean} [options.redisForcePing=true] - Whether to force-enable Redis ping health monitoring * @param {number} [options.redisPingInterval=5000] - Redis ping interval * @param {number} [options.redisPingTimeout=2000] - Redis ping timeout * @param {string} [options.selectionStrategy='fastest'] - Redis node selection strategy {random, round-robin, fastest} * @param {boolean} [options.enableRedisDataCompression=true] - Whether to enable Redis data compression * @param {function} [options.onRedisHealthChange] - Callback function triggered when Redis health status changes. * @param {function} [options.onRedisSubscriptionError] - Callback function triggered when Redis subscription fails. * @param {Array<string>} [options.presetRoomNamespaces=[]] - Predefined WebSocket room namespace * @param {boolean} [options.autoUnsubscribe=true] - Whether to automatically unsubscribe from a room namespace's Redis channel when no clients remain in that namespace. * This only applies to non-predefined (non-preset) room namespaces. * Predefined rooms listed in `presetRoomNamespaces` are not affected and remain subscribed. * @param {Array<string>|string} [options.customChannels] - Custom channels * @param {Array<Object>} [options.redisConfig=[]] - Redis node configuration */ constructor(options = {}) { // Initialize base configuration // Server identifier for cross-server communication. // Must be globally unique across all nodes (e.g., "us-east-1-node3"), do NOT use process.pid. this.serverName = options.serverName; // Current server name this.bridgePrefix = options.bridgePrefix || 'csbp:'; // Cross-server bridge prefix this.wsPrefix = options.wsPrefix || 'ws:'; // WebSocket channel prefix // The two prefixes must not be the same to avoid channel conflicts if (this.bridgePrefix === this.wsPrefix) { throw new Error(`bridgePrefix and wsPrefix must be different to avoid channel conflicts. Received: "${this.bridgePrefix}"`); } // Check if wsOptions is provided and is of type object if (options.wsOptions && typeof options.wsOptions === 'object') { this.wsOptions = options.wsOptions; // Set wsOptions if valid } else { this.wsOptions = null; // Set to null if not provided or invalid } // Set enableWebSocket based on wsOptions this.enableWebSocket = this.wsOptions !== null; // If wsOptions exists, enable WebSocket; otherwise, disable it this.serverPingInterval = options.serverPingInterval || 20000; // WebSocket ping interval this.enterBackgroundCloseTime = options.enterBackgroundCloseTime || 10000; // Close delay after background this.heartbeatStr = options.heartbeatStr || ''; // Heartbeat string // Redis related settings this.redisForcePing = options.redisForcePing !== undefined ? options.redisForcePing : true; // Whether to force-enable Redis ping health monitoring this.redisPingInterval = options.redisPingInterval || 5000; // Redis ping interval this.redisPingTimeout = options.redisPingTimeout || 2000; // Redis ping timeout this.selectionStrategy = options.selectionStrategy || 'random'; // Redis node selection strategy {random, round-robin, fastest} this.enableRedisDataCompression = options.enableRedisDataCompression !== undefined ? options.enableRedisDataCompression : true; // Whether to enable Redis data compression this.onRedisHealthChange = options.onRedisHealthChange; // Callback triggered when the health status of a Redis node changes (e.g. up/down) this.onRedisSubscriptionError = options.onRedisSubscriptionError;// Callback triggered when subscription or unsubscription to a Redis channel fails // Prepare Redis channels this.serverBridgeChannel = this.bridgePrefix + 'server_bridge'; // Server bridge channel this.redisChannels = new Set(); // Initialize channel list this.redisChannels.add(this.serverBridgeChannel); if (this.enableWebSocket) { // Add preset WebSocket room channels from configuration to the channel set. const wsChannels = this._prefixWsChannels(options.presetRoomNamespaces || []); wsChannels.forEach(ch => this.redisChannels.add(ch)) // Add WebSocket private and broadcast channels this.privateChannel = this.wsPrefix + 'private_socket'; this.broadcastChannel = this.wsPrefix + 'broadcast'; this.redisChannels.add(this.privateChannel); this.redisChannels.add(this.broadcastChannel); } if (options.customChannels) { // Add custom channels const customChannels = Array.isArray(options.customChannels) ? options.customChannels : [options.customChannels]; customChannels.forEach(channel => this.redisChannels.add(channel)); // Add custom channels to Set } this.autoUnsubscribe = options.autoUnsubscribe === undefined ? true : options.autoUnsubscribe; // Initialize data structures this.redisConfig = options.redisConfig || []; // Redis node configuration this.redisInstances = []; // Redis instances // Structure: Map<roomNamespace, Map<roomId, Set<socketId>>> // Used to manage all rooms under each room type and their socket members this.rooms = new Map(); // Structure: Map<socketId, Map<roomNamespace, Set<roomId>>> // Used to track all rooms a socket has joined this.socketRooms = new Map(); this.socketMap = new Map(); // Socket ID to socket mapping this.customChannelHandler = null; this.crossServerCallback = {}; // Cross-server callbacks this.crossServerEventListeners = {}; // Cross-server event listeners this.webSocketEventListeners = {}; // WebSocket event listeners this.roundRobinIndex = 0; // Round-robin index for Redis node selection // Determine whether to enable cross-server communication based on redisConfig // If redisConfig exists and contains at least one node, enable cross-service features // Otherwise, operate as a standalone service without Redis communication this.enableCrossServer = this.redisConfig && this.redisConfig.length; // WebSocket service instance this.wss = null; // Initialize Redis service (must come first) if (this.enableCrossServer) { // When cross-server communication is enabled, a string-type serverName must be provided if (!this.serverName || typeof this.serverName !== 'string') { throw new Error("serverName must be specified for cross-server communication to work reliably."); } this._setupRedisServer(); } // Initialize WebSocket service (if enabled) if (this.enableWebSocket) { this._setupWsServer(); } } /** * Initialize event listeners for a Redis instance * * This method listens to connection-related events of a Redis instance * and updates its health status flag `isHealthy` accordingly. * * @param {Redis} redisInstance - Redis client instance * @returns {void} - This function does not return any value. */ _setupRedisEventListeners(redisInstance) { // List of Redis events to listen for const events = ['ready', 'error', 'connect', 'close', 'reconnecting']; events.forEach(event => { redisInstance.on(event, (err) => { let isHealthy; if (event === 'error') { isHealthy = false; // Mark as unhealthy on error debug(`Redis error at ${redisInstance.options?.host || 'Unknown Host'}:${redisInstance.options?.port || 'Unknown Port'} - ${err.message}`); } else { // Mark as healthy unless the event is 'close' or 'reconnecting' isHealthy = !(event === 'close' || event === 'reconnecting'); } // Only invoke the callback if health status changes if (redisInstance.isHealthy !== isHealthy) { redisInstance.isHealthy = isHealthy; this._createAndTriggerHealthStatusChange(redisInstance, isHealthy, event, err); } }); }); } /** * Create the Redis health status information and trigger the health change callback. * * This function prepares the Redis instance health status information and calls the * provided health change callback if it's available. * * @param {Redis} redisInstance - The Redis instance whose health status is being updated. * @param {boolean} isHealthy - The new health status for the Redis instance. * @param {string} event - The event that triggered the health status change. * @param {Object} err - The error object, if any, related to the Redis event. */ _createAndTriggerHealthStatusChange(redisInstance, isHealthy, event, err) { // Prepare the health status information let redisInfo = { host: redisInstance.options?.host || 'Unknown Host', port: redisInstance.options?.port || 'Unknown Port', serverName: this.serverName, event, isHealthy, error: err ? err.message : null, healthySubscriberCount: this.getHealthyRedisInstancesCount('subscriber'), healthyPublisherCount: this.getHealthyRedisInstancesCount('publisher'), totalNodeCount: this.getRedisInstancesCount(), type: redisInstance._customType, // Add the type of the Redis instance (publisher or subscriber) }; // Invoke the health change callback if provided if (this.onRedisHealthChange && typeof this.onRedisHealthChange === 'function') { this.onRedisHealthChange(isHealthy, redisInfo); } } /** * Handles Redis subscription errors and triggers the health status change callback. * This function is responsible for organizing error information and notifying about the status change. * * @param {Redis} redisInstance - The Redis instance that failed to subscribe. * @param {string} channel - The channel to which the subscription failed. * @param {Error} error - The error that caused the subscription failure. * @param {string} event - The event that triggered the subscription action (e.g., "subscribe" or "unsubscribe"). * This parameter helps to distinguish between subscription and unsubscription */ _handleRedisSubscriptionError(redisInstance, channel, error, event) { // Create the error information object let errorInfo = { host: redisInstance.options?.host || 'Unknown Host', port: redisInstance.options?.port || 'Unknown Port', serverName: this.serverName, channel, event, error: error.message, }; // Trigger the health status change callback if provided if (this.onRedisSubscriptionError && typeof this.onRedisSubscriptionError === 'function') { // Notify about the health change with detailed info this.onRedisSubscriptionError(errorInfo); } // Log the error for further investigation // console.error(`Failed to ${event} to Redis channel: ${channel} on ${redisInstance.options?.host || 'Unknown Host'}. Error: ${error.message}`); } /** * Initialize a Redis instance including publisher and subscriber * * This method creates Redis publisher and subscriber clients, sets up event listeners, * subscribes to channels, and handles incoming messages. * * @param {Object} config - Redis connection configuration * @returns {void} - This function does not return any value. */ _initRedisInstance(config) { if (!config) { throw new Error("Redis configuration is required but missing."); } const publisher = new Redis(config); // Create a new independent Redis client instance based on the configuration of the publisher // using the duplicate() method. The new subscriber shares the same settings but maintains // a separate connection to avoid mutual interference. const subscriber = publisher.duplicate(); publisher.isHealthy = subscriber.isHealthy = false; publisher.latencyTime = 0; publisher._customType = 'publisher'; subscriber._customType = 'subscriber'; this._setupRedisEventListeners(publisher); this._setupRedisEventListeners(subscriber); // Subscribe to all Redis channels to receive messages from different services or WebSocket rooms this.redisChannels.forEach(channel => { subscriber.subscribe(channel, (err) => { if (err) { //console.error(`Failed to subscribe to ${channel} channel: ${err.message}`); this._handleRedisSubscriptionError(subscriber, channel, err, 'subscribe'); } }); }); // ========================== Redis Message Subscription Logic ========================== // // Depending on whether message compression is enabled, choose different event types: // // - If compression is enabled, and a `notepack` instance is provided: // Use notepack.encode() to publish binary messages, and listen to `messageBuffer` to decode. // // - If compression is not enabled: // Expect JSON strings, listen to `message`, and parse using JSON.parse(). // // ⚠️ Warning: // `ioredis` provides two events: // - `message`: receives channel and message as strings // - `messageBuffer`: receives both as Buffer (for binary data) // The event must match the publish format, or it may result in corrupted data or errors. // ======================================================================================= if (this.enableRedisDataCompression) { subscriber.on('messageBuffer', (channel, message) => { try { message = notepack.decode(message); this._handleRedisIncomingMessage(channel.toString(), message); } catch (err) { console.error(`Decompression failed for message on channel ${channel}. Error details:`, err); } }); } else { subscriber.on('message', (channel, message) => { try { message = JSON.parse(message); this._handleRedisIncomingMessage(channel, message); } catch (err) { console.error(`Parsing failed for message on channel ${channel}. Error details:`, err); } }); } this.redisInstances.push({ publisher, subscriber }); } /** * This function sets up the Redis server for both publisher and subscriber. * It establishes Redis connections, subscribes to channels, and handles incoming messages. * * @returns {void} - This function does not return any value. */ _setupRedisServer() { // Loop through each Redis configuration and initialize the publisher and subscriber for (let config of this.redisConfig) { this._initRedisInstance(config); } // If there are multiple Redis instances, and the force ping monitoring (redisForcePing) is enabled, // or the selection strategy is "fastest", then start the ping monitoring timer. // Periodically sends ping requests to each Redis node to measure response times, // dynamically selecting the fastest node among the available ones for requests. if ( this.redisInstances.length > 1 && (this.redisForcePing || this.selectionStrategy === 'fastest') ) { this._startRedisPingTimer(); } } /** * Starts a timer that periodically pings Redis publisher instances to check their health. * Pings are sent at a set interval and each ping has a timeout. If the ping response time exceeds * the specified timeout, the Redis instance is marked as unhealthy. * @returns {void} - This function does not return any value. */ _startRedisPingTimer() { if (!this.redisPingInterval || !this.redisPingTimeout) { debug('Redis ping interval or timeout not configured correctly.'); return; } // If the ping timer is already running, return if (this.redisPingTimer) return; // Set an interval to periodically check the health status of Redis nodes this.redisPingTimer = setInterval(() => { // Loop through each Redis instance this.redisInstances.forEach(({ publisher }) => { const start = Date.now(); // Ping Redis node and check for timeout publisher.ping().then(() => { const latency = Date.now() - start; publisher.latencyTime = latency; // If latency exceeds the predefined timeout value, mark the Redis node as unhealthy const newHealthStatus = latency > this.redisPingTimeout ? false : true; // Only update health status if it has changed if (publisher.isHealthy !== newHealthStatus) { publisher.isHealthy = newHealthStatus; if (!publisher.isHealthy) { console.error(`Redis node ping exceeded timeout: ${latency}ms`); } // Optionally trigger a callback for health status change this._createAndTriggerHealthStatusChange(publisher, publisher.isHealthy, 'ping', null, 'publisher'); } }).catch(() => { // If the ping fails, mark the Redis node as unhealthy if (publisher.isHealthy !== false) { publisher.isHealthy = false; console.error('Ping failed for Redis node'); // Optionally trigger a callback for health status change this._createAndTriggerHealthStatusChange(publisher, publisher.isHealthy, 'ping', null, 'publisher'); } }); }); }, this.redisPingInterval); } /** * Sets up a WebSocket server to handle client connections and events. * @returns {void} - This function does not return any value. */ _setupWsServer() { try { // Unified event handler for all WebSocket events const handleSocketEvent = (eventName, socket, ...args) => { // Built-in WebSocket event handling (e.g., heartbeat management) switch (eventName) { case 'connection': socket.isAlive = true; socket.__enterBackgroundTime = 0; break; case 'pong': socket.isAlive = true; break; } // Trigger user-registered event listeners const listeners = this.webSocketEventListeners[eventName]; if (listeners) { listeners.forEach(({ fn, once }) => { try { fn(socket, ...args); } catch (err) { console.error(`[${eventName}] Listener execution failed:`, err); } if (once) this.offWebSocketEvent(eventName, fn); }); } }; // Based on wsOptions configuration, create a WebSocket server this.wss = new WebSocket.Server(this.wsOptions); // Handle new client connections this.wss.on('connection', (socket, req) => { // Register core event listeners const coreEvents = ['close', 'error', 'pong']; coreEvents.forEach(event => { socket.on(event, (...args) => handleSocketEvent(event, socket, req, ...args)); }); // Listen for messages from the client socket.on('message', (message) => { if (message === null || message === undefined) return; // Prevent handling null/undefined /** * Supplement: * Native WebSocket messages may be Buffers (binary). * Use toString() to ensure consistent string processing. */ message = message.toString(); if (message === this.heartbeatStr) { // If it is a heartbeat message, respond handleSocketEvent('client-ping', socket); socket.send(this.heartbeatStr); } else { // Handle business-related messages this._handleWebSocketMessage(socket, message); } // Manually trigger custom 'message' event handleSocketEvent('message', socket, message); }); // Manually trigger 'connection' event handleSocketEvent('connection', socket, req); }); // Server-level events this.wss.on('error', (err) => handleSocketEvent('server-error', err)); this.wss.on('listening', () => handleSocketEvent('listening')); // Handle server close this.wss.on('close', () => { if (this.wsTimer) { clearInterval(this.wsTimer); } handleSocketEvent('ws-server-close'); }); // Periodically ping clients to check connection status this.wsTimer = setInterval(() => { this.wss.clients.forEach((ws) => { // Handle automatic disconnection when client goes to background if (ws.__enterBackgroundTime) { const pass = Date.now() - ws.__enterBackgroundTime; if (pass > this.enterBackgroundCloseTime) { return ws.terminate(); } } // Check if the client is alive if (ws.isAlive === false) { return ws.terminate(); } // Send ping to client and wait for pong response ws.isAlive = false; ws.ping(); }); }, this.serverPingInterval); } catch (e) { // WebSocket server startup failed throw new Error("Failed to start WebSocket server: " + e.message); } } /** * Handles incoming messages from Redis channels. * @param {string} channel - The channel the message was received from. * @param {Object} message - The message object received. * @returns {void} - This function does not return any value. */ _handleRedisIncomingMessage(channel, message) { // Ensure all required fields are present before proceeding if (!channel || !message) { debug(`[RedisMessageHandler] Missing required fields. channel or message or message.data`); return; }; // If the sending server and target server names are the same, it's a local message already processed, // so we skip further handling and exit early. if (message.senderServer === this.serverName || message.data?.senderServer === this.serverName) { debug(`Skipping message from self. senderServer: ${message.senderServer || message.data?.senderServer}, currentServer: ${this.serverName}`); return; } if (channel === this.serverBridgeChannel) { this._handleServerBridgeMessage(message); } else if (this._isWebSocketChannel(channel)) { this._handleWsRouteMessage(channel, message); } else { if (this.customChannelHandler) this.customChannelHandler(channel, message); } } /** * Handles incoming server bridge messages by executing event listeners and callback handling. * * @param {Object} message - The parsed message content, including target server, callback ID, and other data. * @param {string} message.senderServer - The server name that sent the message. * @param {string} message.targetServer - The target server name. * @param {string} message.callbackId - The callback ID used to identify and handle asynchronous callbacks. * @param {boolean} message.isCallback - Whether it is a callback message. * @param {string} message.event - The event name that determines how the message will be handled. * @returns {void} - This function does not return any value. */ _handleServerBridgeMessage(message) { if (!message.data || typeof message.data !== 'object') { throw new Error('Invalid message: "data" must exist and be of type object'); } const { senderServer, targetServer, callbackId, isCallback, event } = message.data; // Handle callback messages if (isCallback) { // Skip if senderServer is invalid if (!senderServer) return; const cb = this.crossServerCallback[callbackId]; // Skip if callback doesn't exist if (!cb) return; // If callback is a Promise, resolve it with the message data if (cb.resolveFunction) cb.resolveFunction(message.data); // If callback is a function, execute it and pass the message data if (cb.callbackFunction) { // Only decrement expectedResponses if a callbackFunction is invoked (because cb.resolveFunction already handles this) // Decrease the expected response count after receiving one valid response cb.expectedResponses--; cb.callbackFunction({ success: true, data: message.data, remainingResponses: cb.expectedResponses // Indicate how many responses are still pending }); } // Once all expected responses have been received, clear the timeout and remove the callback entry if (cb.expectedResponses <= 0) { if (cb.timeoutId) clearTimeout(cb.timeoutId); delete this.crossServerCallback[callbackId]; } return; } // Process event listener logic for non-callback messages // Skip if targetServer is not an array or the current server is not in the targetServer list if (!Array.isArray(targetServer) || !(targetServer.length == 0 || targetServer.includes(this.serverName))) return; // Invoke the registered cross-server event listeners this._invokeCrossServerListeners(event, message.data, callbackId, senderServer); } /** * Handles incoming Redis channel messages by routing them to the appropriate WebSocket connections. * * @param {string} channel - The Redis channel the message was received from. * @param {Object} message - The parsed message content. * @param {Object} message.data - The actual data of the message, containing the information to be sent. * @param {Array} message.socketIds - List of WebSocket connection IDs (for private channels). * @param {string} message.socketId - A single WebSocket connection ID (for private channels). * @param {string} message.roomNamespace - The room name (for messages to specific rooms). * @param {string} message.roomId - The room ID (for messages to specific rooms). * @returns {void} - This function does not return any value. */ _handleWsRouteMessage(channel, message) { if (!message.data || typeof message.data !== 'string') { throw new Error('Invalid message: "data" must exist and be of type string'); } // Route messages based on the channel if (channel === this.privateChannel) { // Handle private channel messages if (message.socketIds) { // If message contains multiple socketIds, send to multiple WebSockets message.socketIds.forEach(socketId => { if (typeof socketId === 'string' && socketId) { const ws = this.socketMap.get(socketId); if (ws && ws.readyState === WebSocket.OPEN) { ws.send(message.data); } } }); } else if (message.socketId) { // If message contains a single socketId, send to one WebSocket if (typeof message.socketId === 'string' && message.socketId) { const ws = this.socketMap.get(message.socketId); if (ws && ws.readyState === WebSocket.OPEN) { ws.send(message.data); } } } } else if (channel === this.broadcastChannel) { // Handle broadcast channel messages this.wss.clients.forEach((ws) => { if (ws && ws.readyState === WebSocket.OPEN) { ws.send(message.data); } }); } else { // Send to specific roomNamespace if (message.roomNamespace) { const sockets = this.getRoomSocketIds(message.roomNamespace, message.roomId); sockets.forEach(socketId => { // Skip excluded socketIds if (message.excludeSocketIds && message.excludeSocketIds.includes(socketId)) return; const ws = this.socketMap.get(socketId); if (ws && ws.readyState === WebSocket.OPEN) { ws.send(message.data); } }); } } } /** * Processes the incoming WebSocket message, parses it, and triggers relevant event listeners. * * @param {WebSocket} socket - The WebSocket client instance that received the message. * @param {string} message - The message received from the client. * @returns {void} - This function does not return any value. */ _handleWebSocketMessage(socket, message) { // Check if socket is valid if (!socket) { debug('Invalid socket instance.'); return; } // Check if message is a valid string if (typeof message !== 'string') { debug('Invalid message type. Message must be a string.'); return; } // Attempt to parse the message, handle any parsing error try { message = JSON.parse(message); } catch (err) { console.error('Failed to parse socket message to JSON:', err); return; } // Check if the message has an event property if (!message || !message.event || typeof message.event !== 'string') { debug(`[_handleWebSocketMessage] Invalid message or message.event}`); return; }; // Find relevant listeners for the event const listeners = this.webSocketEventListeners[message.event]; if (!listeners) return; // Validate callbackId: must be a non-empty string with acceptable length const isCallback = typeof message.callbackId === 'string' && message.callbackId.length > 0 && message.callbackId.length <= 64; // Handle callback-based listeners const sendCallback = isCallback ? (data) => { if (socket.readyState === WebSocket.OPEN) { socket.send(JSON.stringify({ payload: data, callbackId: message.callbackId })); } } : null; // Process each listener listeners.forEach(({ fn, once }) => { try { // Call the listener function and pass the sendCallback fn(socket, message.payload, sendCallback); } catch (err) { // Handle listener execution failure console.error(`[${message.event}] Listener execution failed:`, err); } // If the listener is "once", remove it after execution if (once) { this.offWebSocketEvent(message.event, fn); } }); } /** * Invoke registered cross-server event listeners * * @param {string} event - Event name * @param {object} data - Data passed with the event * @param {string} [callbackId] - Optional callback ID for cross-server response * @param {string} [targetServer] - Origin server name for replying callback * @returns {void} */ _invokeCrossServerListeners(event, data, callbackId, targetServer) { // If event name is invalid or there are no listeners registered, log the debug message and return early if (!event || !this.crossServerEventListeners[event]) { debug(`_invokeCrossServerListeners: Invalid event name or no listeners registered for event: ${event}`); return; } // Server-to-server callback (reply to the original requesting server) const serverCallback = callbackId ? (callBackData) => { // `_Cs_Cb_` is a built-in event name reserved for cross-service callback handling this.emitCrossServer('_Cs_Cb_', { targetServer: [targetServer], callbackId, isCallback: true, payload: callBackData }); } : null; // Iterate through all registered listeners and execute them this.crossServerEventListeners[event].forEach(({ fn, once }) => { try { // Execute the listener function with event data and optional callbacks fn(data, serverCallback); } catch (err) { console.error(`Local listener error for ${event}:`, err); } // If the listener is once-only, remove it after execution if (once) this.offCrossServerEvent(event, fn); }); } /** * Retrieves the best healthy Redis instance based on the configured selection strategy. * - 'random' : Randomly selects one healthy Redis instance. * - 'fastest' : Selects the Redis instance with the lowest latency. * - 'round-robin' : Selects Redis instances in a round-robin fashion. * * @returns {Object} - The best healthy Redis instance based on the selection strategy. */ _getHealthyRedisInstance() { // Filter for healthy Redis instances const healthyInstances = this.redisInstances.filter(instance => instance.publisher.isHealthy); //console.log('Number of healthy nodes: ' + healthyInstances.length); // If no healthy Redis instances are available, return error if (healthyInstances.length === 0) { debug("No healthy Redis instances available."); return null; } // If only one healthy Redis instance, return it directly if (healthyInstances.length === 1) { return healthyInstances[0]; } // The selected instance let selectedInstance; // Select Redis instance based on selection strategy switch (this.selectionStrategy) { case 'random': // Randomly select a healthy Redis instance const randomInstanceIndex = Math.floor(Math.random() * healthyInstances.length); selectedInstance = healthyInstances[randomInstanceIndex]; break; case 'fastest': // Select the Redis instance with the smallest latency selectedInstance = healthyInstances.reduce((prev, current) => prev.publisher.latencyTime < current.publisher.latencyTime ? prev : current ); break; case 'round-robin': // Simple round-robin selection strategy // Explanation: // 1. Each time, an instance is selected from healthyInstances based on roundRobinIndex. // 2. roundRobinIndex starts at 0 and increments (++) after each selection. // 3. Modulo operation (% healthyInstances.length) ensures the index wraps around within valid bounds. // 4. This achieves a fair round-robin distribution of requests among the servers. this.roundRobinIndex = (this.roundRobinIndex || 0) % healthyInstances.length; selectedInstance = healthyInstances[this.roundRobinIndex++]; break; default: // Default to the first healthy instance if no strategy matched selectedInstance = healthyInstances[0]; break; } debug(`Strategy is ${this.selectionStrategy}, latency: ${selectedInstance.publisher.latencyTime}ms, port:${selectedInstance.publisher.options.port}, host:${selectedInstance.publisher.options.host}`); return selectedInstance; } /** * Adds a regular event listener to handle events sent from other servers. * * @param {string} event - Event name (string or number). * @param {Function} listener - Event handler function. * @param {string|number} [tag] - (Optional) A custom tag (string or number) to identify the listener for future removal. * @returns {void} */ onCrossServerEvent(event, listener, tag) { if (!this.enableCrossServer) return; // Ensure event is a non-empty string if (!event || typeof event !== 'string') { throw new TypeError('event must be a non-empty string'); } // Ensure listener is a function if (typeof listener !== 'function') { throw new TypeError('listener must be a function'); } if (!this.crossServerEventListeners[event]) this.crossServerEventListeners[event] = []; this.crossServerEventListeners[event].push({ fn: listener, once: false, tag }); } /** * Adds a one-time event listener to handle events sent from other servers, triggered only once. * * @param {string} event - Event name (string or number). * @param {Function} listener - Event handler function. * @param {string|number} [tag] - (Optional) A custom tag (string or number) to identify the listener for future removal. * @returns {void} */ onceCrossServerEvent(event, listener, tag) { if (!this.enableCrossServer) return; // Ensure event is a non-empty string if (!event || typeof event !== 'string') { throw new TypeError('event must be a non-empty string'); } // Ensure listener is a function if (typeof listener !== 'function') { throw new TypeError('listener must be a function'); } if (!this.crossServerEventListeners[event]) this.crossServerEventListeners[event] = []; this.crossServerEventListeners[event].push({ fn: listener, once: true, tag }); } /** * Removes a listener for a cross-server event, supports removal by function or tag. * * @param {string} event - The event name to identify the event to remove the listener from. * @param {function|string|number} [listenerOrTag] - Optional. If a function, removes the specific listener. * If a string or number, removes all listeners with the matching tag. If omitted, removes all listeners for the event. * @returns {void} */ offCrossServerEvent(event, listenerOrTag) { // Ensure event is a non-empty string if (!event || typeof event !== 'string') { throw new TypeError('event must be a non-empty string'); } const listeners = this.crossServerEventListeners[event]; if (!listeners) return; if (listenerOrTag === undefined) { // Remove all listeners for the event delete this.crossServerEventListeners[event]; } else if (typeof listenerOrTag === 'function') { // Remove specific listener function this.crossServerEventListeners[event] = listeners.filter(item => item.fn !== listenerOrTag); } else { // Remove listeners by tag this.crossServerEventListeners[event] = listeners.filter(item => item.tag !== listenerOrTag); } } /** * Publishes a message to a specified Redis channel, for internal room broadcasts or custom channel communications. * * @param {string} channel - The Redis channel to publish to. * @param {Object} message - The message object to send * @returns {boolean} Whether the publish operation was successful */ publishRedisMessage(channel, message) { if (!channel || typeof channel !== 'string') { throw new TypeError('channel must be a non-empty string'); } // If cross-server functionality is not enabled, return false if (!this.enableCrossServer) { debug("Cross-server functionality is not enabled."); return false; } if (!message || typeof message !== 'object' || Array.isArray(message)) { debug('publishRedisMessage: Invalid argument: message must be a non-empty object'); return false; } const selectedInstance = this._getHealthyRedisInstance(); if (!selectedInstance) { debug("No healthy Redis instance available."); return false; } let packet; try { // Construct the message packet if (this.enableRedisDataCompression) { // If compression is enabled, use notepack to encode packet = notepack.encode(message); } else { // If not using compression, use JSON serialization packet = JSON.stringify(message); } // Only publish after successful construction selectedInstance.publisher.publish(channel, packet); return true; } catch (error) { debug(`Failed to serialize or publish message on channel "${channel}":`, error); return false; } } /** * Publish a message to target servers or handle cross-server callback and events. * * @param {string} event - Event name. * @param {any} message - The message payload to send. Must not be null or undefined. * @param {Function} [callback] - Optional callback function to handle responses from target servers. * @param {Object} [options] - Optional configuration object. * @param {string|string[]} [options.targetServer=[]] - The target server(s) to send the message to. * An empty array or string indicates broadcast to all servers. * @param {number} [options.timeout=1000] - Callback timeout in milliseconds. Ignored if no callback is provided. * @param {number} [options.expectedResponses=1] - The expected number of server responses. * In broadcast mode, defaults to 1 if not explicitly provided. * In non-broadcast mode, it is set to the number of target servers. * @param {boolean} [options.exceptSelf=false] - When broadcasting, whether to exclude the current server from handling the message. * @returns {void} */ emitCrossServer(event, message, callback, options = {}) { // If cross-server functionality is not enabled, return early if (!this.enableCrossServer) { debug("Cross-server functionality is not enabled."); return; } // Ensure event is a non-empty string if (!event || typeof event !== 'string') { throw new TypeError('event must be a non-empty string'); } // Parameter validation: message must not be null or undefined if (message === null || message === undefined) { throw new Error('emitCrossServer: message cannot be null or undefined'); } // Destructure options with default values for target servers, timeout, expected response count, and self-exclusion flag let { targetServer = [], timeout = 5000, expectedResponses = 1, exceptSelf = false } = options; // Ensure timeout is a positive integer, otherwise use the default value of 5000 if (!Number.isInteger(timeout) || timeout <= 0) { timeout = 5000; } // Construct initial data packet with message, event name, and sender server identifier let data = { event, senderServer: this.serverName }; if (event === '_Cs_Cb_' && typeof message === 'object' && message.isCallback) { // If the message is a callback, merge the message data into the data object. data = { ...data, ...message }; } else { // If not a callback, just add the message to the data payload. data.payload = message; } // Normalize target server input const targets = Array.isArray(targetServer) ? targetServer : (typeof targetServer === 'string' && targetServer) ? [targetServer] : []; // Update data.targetServer data.targetServer = targets; // Check if the message is a broadcast type const isBroadcast = targets.length === 0; if (isBroadcast