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
JavaScript
/**
* 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