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.

810 lines (725 loc) 39.6 kB
/** * Copyright (c) 2025 LiuYiSong * Email: 349233775@qq.com * https://github.com/LiuYiSong/websocket-cross-server-adapter * All rights reserved. * * WebSocketConnector Client Class * * This class manages the lifecycle of a WebSocket client connection. * It provides automatic and manual reconnection, heartbeat support, event-based communication with response callbacks, * and is suitable for frontend or Node.js clients requiring stable bidirectional communication. * */ 'use strict'; let WebSocket; if (typeof window !== 'undefined' && typeof window.WebSocket !== 'undefined') { WebSocket = window.WebSocket; } else { WebSocket = require('ws'); } class WebSocketConnector { /** * Constructor function to initialize the WebSocket client instance. * The constructor accepts an options object `options` and sets the default configuration for the WebSocket client, * while allowing the user to provide custom configurations. * * @param {Object} options - User-provided configuration options to override the defaults. * @param {string} options.url - Full WebSocket connection URL, must start with `ws://` or `wss://`, e.g., `ws://127.0.0.1:8081` or `wss://example.com/chatroom`. * Must include protocol, domain or IP, optional port, and optional path. / * @param {number} options.pingInterval - Time interval to trigger a ping, default is 10000 ms. * @param {number} options.pongTimeout - Duration to wait for a pong response after sending ping (in ms), default is 2000. * If no pong is received within this duration, the connection is considered broken and will be closed. * @param {number} options.fastReconnectThreshold - Maximum number of fast reconnection attempts, default is 3 times. * @param {number} options.fastReconnectInterval - Time interval between attempts during the fast reconnection phase, default is 3000 ms. * @param {number} options.reconnectMaxInterval - Maximum interval between reconnection attempts after exponential backoff, default is 120_000 ms. * @param {string} options.pingMsg - Ping message sent to the server, default is "". * @param {number} options.callbackTimeout - Timeout duration in milliseconds for event responses with callbacks or Promises. Default is 5000 ms. * When sending an event using `emit(event, data, callback)` or `await emitWithPromise(event, data)`, * if no response is received from the server within the configured timeout duration, it is considered a timeout: * For callback usage, the callback will be invoked with a timeout error; * For Promise usage, it will resolve with an object like `{ success: false, error: string }`. * @param {number} options.repeatLimit - Maximum number of reconnection attempts, default is unlimited (null). * @param {number} options.pendingTimeout - Timeout to trigger pending callbacks, default is 100 ms. * After calling `emit`, if no server response (neither success nor failure) is received within this duration, * a pending handler (e.g., showing a loading UI) will be triggered. This helps provide user feedback like "processing…". * Once the response arrives, this handler won't be called again. * Note that `pendingTimeout` must be less than `callbackTimeout` to function properly, * as it triggers the pending handler before the callback timeout occurs. * @param {Object} options.customParams - Custom additional parameters that This object contains key-value pairs representing the parameters and their values. */ constructor(options) { // Default configuration options this.options = { // WebSocket connection URL, must be a complete URL starting with ws:// or wss://, // e.g., ws://127.0.0.1:8081 or wss://example.com/chat. // Must include protocol, domain/IP, optional port, and optional path. url: '', pingInterval: 10000, // Time interval to trigger ping pongTimeout: 2000, // Timeout for no pong response after ping //autoReconnect: true, // Whether to enable auto-reconnect (enabled by default) fastReconnectThreshold: 3, // Maximum fast reconnect attempts fastReconnectInterval: 3000, // Fast reconnect interval reconnectMaxInterval: 120_000, // Maximum interval pingMsg: '', // Ping message sent to the server callbackTimeout: 5000, // Timeout for callback functions repeatLimit: null, // Maximum reconnection attempts pendingTimeout: 100, // Timeout for pending callback triggers (in ms) customParams: {} // Custom additional parameters }; // Merge provided options with defaults this.options = { ...this.options, ...options }; // Connection state tracking this.pingGoTime = 0; // Last ping time (used to calculate latency) this.repeat = 0; // Current reconnection attempt count // Event and callback storage this.webSocketEventListeners = {}; // Registered event listeners this.socketCallbacks = {}; // Store callbacks, timers, and callback ID mappings // Timer references this.pingTimer = null; // Ping timer this.pongTimer = null; // Pong timer // Initialize WebSocket connection this._createWebSocket(); } /** * Creates a WebSocket connection and binds event handlers. * This function attempts to connect to the WebSocket server and binds the `onopen`, `onmessage`, `onerror`, and `onclose` event handlers. * If the connection fails, the reconnection mechanism will be triggered. * * @returns {void} */ _createWebSocket() { try { if (!this.options.url) { throw new Error("Missing WebSocket URL: please provide a complete ws:// or wss:// address in options.url."); } let url = this.options.url; const customParams = this.options.customParams; if (customParams && typeof customParams === 'object' && !Array.isArray(customParams)) { const queryParams = new URLSearchParams(); for (const [key, value] of Object.entries(customParams)) { queryParams.append(key, String(value)); } if (queryParams.toString()) { const separator = url.includes('?') ? '&' : '?'; url += separator + queryParams.toString(); } } this.ws = new WebSocket(url); /** * In a Node.js environment, using `this.ws.on('open', function open() {})` style will receive binary data (Buffer) in the `message` event, * requiring manual parsing by the developer. * Using `this.ws.onopen = function() {}` style will receive a `MessageEvent` object, and the actual data must be accessed via `message.data`. * Node.js ws client supports both `on` and `onopen` registration styles, * but the browser's built-in WebSocket only supports `onopen = function()`. * To maintain consistency between browser and Node.js environments, we adopt `onopen = function()` here, * and always access the actual data from `message.data` in the `message` event. * in Node.js it is typically a string or Buffer. When processing `message.data`, * ensure proper handling based on the actual data type. * If you prefer handling binary (Buffer) data in Node.js, you should modify the handling logic here. */ this.ws.onopen = this._onOpen.bind(this); this.ws.onmessage = this._onMessage.bind(this); this.ws.onerror = this._onError.bind(this); this.ws.onclose = this._onClose.bind(this); } catch (e) { this.reconnect(); console.error('Failed to connect to WebSocket server:', e); } } /** * Handles the WebSocket connection open event. * This function triggers the `onopen` event callback and resets the reconnection attempts (`repeat`). * After that, it starts the heartbeat mechanism. * * @param {Event} event - The event object when the WebSocket connection is opened. * @returns {void} */ _onOpen(event) { this._executeListeners("open", event); // Execute the onopen callback function. this.repeat = 0; this._heartCheck(); } /** * Handles the WebSocket connection close event. * This function triggers the `onclose` event callback and provides information about the closure. * * The connection could be closed due to various reasons, such as: * - Server-side closure, for example, authentication failure, forced disconnection, etc. * * The `event.code` provides detailed information about the closure reason: * - For example, a code of `1000` indicates a normal closure, while other codes may indicate specific errors. * * In this case, the reconnection logic (`this.reconnect()`) is not automatically triggered, as the reason for the closure may require custom handling. * Developers can listen for the `onclose` event and decide whether to attempt reconnection based on the `event.code` or other application-specific logic. * * @param {Event} event - The event object when the WebSocket connection is closed, containing `event.code` and other details. * @returns {void} */ _onClose(event) { // console.log('Connection closed ===', event.code, event.reason); // Execute the registered `onclose` callback functions (if any). this._executeListeners("close", event); // Developers are responsible for handling the reconnection logic based on event.code or other criteria. // this.reconnect(); } /** * Handles the WebSocket error event. * This function triggers the `onerror` event callback and attempts to reconnect. * * When an error occurs during the WebSocket communication (e.g., network issues, server unavailability), * this function will be triggered. The error event provides details about the nature of the error. * * @param {Event} event - The event object when a WebSocket error occurs, containing details about the error. * @returns {void} */ _onError(event) { // Execute the registered `onerror` callback functions (if any). this._executeListeners("error", event); // Attempt to reconnect after the error occurs. // The reconnection logic can be customized by the developer, depending on the error. this.reconnect(); } /** * Handles the received message. * * This function first checks if the message is valid. If invalid, it returns immediately. * Then, it executes the `onmessage` event callback and resets the heartbeat timer. * If the message data matches the `pingMsg` configuration, it indicates a pong response. * It calculates and executes the `onpong` event callback, passing the round-trip delay (ping time). * Otherwise, it treats the message as a normal message and calls the `_handleMessage` method. * * @param {Object} message - The received message object. * @returns {void} */ _onMessage(message) { /** * Explanation: * - `!message` checks if the message object exists; if not, it returns immediately. * - `message.data === undefined` checks if the `data` field in the message is `undefined`. * We are only concerned with whether the `data` field exists, not whether it is an empty string (""). * This is because we might have agreements, such as a heartbeat packet being an empty string (""), and in that case, we still want to proceed with subsequent logic. */ if (!message || message.data === undefined) return; this._executeListeners("message", message.data); if (message.data === this.options.pingMsg) { // Handle pong response let now = (new Date()).getTime(); let speed = now - this.pingGoTime; this._executeListeners("pong", speed); this._heartCheck(); } else { this._handleMessage(message.data); } } /** * Handles the received messages. * * This function attempts to parse the incoming JSON data. If parsing fails, it ignores the message. * If the message contains a `callbackId`, it finds and executes the callback or Promise associated with that ID. * If the message contains an `event`, it triggers the corresponding event and executes registered listeners. * * @param {string} data - The received message data. * @returns {void} */ _handleMessage(data) { try { data = JSON.parse(data); } catch (err) { return; } if (!data) return; // Handle callback responses if (data.callbackId) { let cb = this.socketCallbacks[data.callbackId]; if (cb) { // Execute the corresponding callback function if (cb.callback) cb.callback(null, data.payload); if (cb.resolve) cb.resolve({ success: true, data: data.payload }); // Clean up timers and references if (cb.timeoutId) clearTimeout(cb.timeoutId); if (cb.pendingTimer) clearTimeout(cb.pendingTimer); delete this.socketCallbacks[data.callbackId]; } return; } // Handle event-triggered messages if (data.event) this._executeListeners(data.event, data.payload); } /** * Executes all listener functions for a specified event. * * This function first checks if the event is valid, then finds and executes all registered listeners. * If the listener is set to execute only once, it is removed after execution. * * @param {string} event - The event name to be triggered. * @param {any} data - The data to be passed to the listeners. * @returns {void} */ _executeListeners(event, data) { // 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.webSocketEventListeners[event]; if (listeners) { // Execute all listeners listeners.forEach(({ fn, once }) => { try { fn(data); } catch (err) { console.error(`[${event}] Listener execution failed:`, err); } if (once) this.off(event, fn); }); } } /** * Registers an event listener function that gets executed when the event is triggered. * * @param {string} event - The name of the event to listen for. * @param {function} listener - The callback function to be executed when the event is triggered. * @param {string|number} [tag] - (Optional) A custom tag (string or number) to identify the listener for future removal. * @returns {void} */ on(event, listener, tag) { // 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.webSocketEventListeners[event]) this.webSocketEventListeners[event] = []; this.webSocketEventListeners[event].push({ fn: listener, once: false, tag }); } /** * Registers a one-time event listener function that gets executed once and then automatically removed. * * @param {string} event - The name of the event to listen for. * @param {function} listener - The callback function to be executed when the event is triggered. * @param {string|number} [tag] - (Optional) A custom tag (string or number) to identify the listener for future removal. * @returns {void} */ once(event, listener, tag) { // 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.webSocketEventListeners[event]) this.webSocketEventListeners[event] = []; this.webSocketEventListeners[event].push({ fn: listener, once: true, tag }); } /** * Removes an event listener function and unregisters it for that event. * * @param {string} event - The name of the event to remove the listener for. * @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} */ off(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.webSocketEventListeners[event]; if (!listeners) return; if (listenerOrTag === undefined) { delete this.webSocketEventListeners[event]; } else if (typeof listenerOrTag === 'function') { this.webSocketEventListeners[event] = listeners.filter(item => item.fn !== listenerOrTag); } else { this.webSocketEventListeners[event] = listeners.filter(item => item.tag !== listenerOrTag); } } /** * Sends data with an event to the WebSocket server and optionally handles the callback and pending behavior. * * This function checks the WebSocket connection state before sending. * If connected, it sends the data. If a callback is provided, a unique callback ID is generated and attached, * and the callback is registered to handle the server response. * * @param {string} event - The event name to be sent. * @param {any} data - The data payload to send. Must not be null or undefined. * @param {function} [callback] - Optional response callback function. * @param {Object} [options] - Optional extra options object. * @param {number} [options.callbackTimeout] - Timeout for the callback function in milliseconds. * @param {function} [options.onPending] - Function triggered while waiting for callback. * @param {number} [options.pendingTimeout] - Timeout before triggering onPending in milliseconds. * @returns {void} */ emit(event, data, callback, options = {}) { // Ensure event is a non-empty string if (!event || typeof event !== 'string') { throw new TypeError('event must be a non-empty string'); } // Parameter validation: data must not be null or undefined if (data === null || data === undefined) { throw new Error('emit: data cannot be null or undefined'); } // Protective check: this.ws might be null during reconnecting. // Accessing readyState in that case would throw TypeError. if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { if (callback && typeof callback === 'function') { callback({ error: 'WebSocket is not open', errorCode: 'WS_NOT_OPEN', }, null); } return; } const message = { event, payload: data, // User data is placed inside the payload to avoid confusion } // If a callback is provided, generate ID and register it. if (callback && typeof callback === 'function') { const callbackId = this._generateCallbackId(); message.callbackId = callbackId; this._registerSocketCallback({ callbackId, type: 'callback', fn: callback, onPending: options.onPending, pendingTimeout: options.pendingTimeout || this.options.pendingTimeout, callbackTimeout: options.callbackTimeout || this.options.callbackTimeout, }); } this.ws.send(JSON.stringify(message)); } /** * Sends event data and returns a Promise to handle the response or timeout. * * If the connection is open, the function sends the data and registers a callback. * It supports response timeout and a pending handler before timeout. * If the connection is invalid, it returns an error directly. * * @param {string} event - The event name to be sent. * @param {any} data - The data payload to send. Must not be null or undefined. * @param {Object} [options] - Optional configuration object. * @param {function} [options.onPending] - Callback to be invoked before timeout if response is pending. * @param {number} [options.pendingTimeout] - Timeout duration for the pending callback in milliseconds. * @param {number} [options.callbackTimeout] - Timeout duration for the response callback in milliseconds. * @returns {Promise<Object>} - Returns a Promise containing either response data or error info. */ emitWithPromise(event, data, options = {}) { return new Promise((resolve) => { // Ensure event is a non-empty string if (!event || typeof event !== 'string') { return resolve({ success: false, error: 'No event provided in emitWithPromise' }); } // Parameter validation: data must not be null or undefined if (data === null || data === undefined) { return resolve({ success: false, error: 'Data cannot be null or undefined' }); } // Check if WebSocket connection is available. if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { return resolve({ success: false, error: 'WebSocket is not open', errorCode: 'WS_NOT_OPEN' }); } // Generate callback ID and register the callback. const callbackId = this._generateCallbackId(); this._registerSocketCallback({ callbackId, type: 'promise', fn: resolve, onPending: options.onPending, pendingTimeout: options.pendingTimeout || this.options.pendingTimeout, callbackTimeout: options.callbackTimeout || this.options.callbackTimeout }); const message = { event, payload: data, // User data is placed inside the payload to avoid confusion callbackId } this.ws.send(JSON.stringify(message)); }); } /** * Registers the callback function for WebSocket messages and handles timeout and pending logic. * * This function registers the callback function, timeout logic, and pending logic into `socketCallbacks`. * It sets the timeout based on the provided options and calls the corresponding error handling function after the timeout. * If a pending callback function is provided, it will be called within the specified time. * * @param {Object} options - The options for registering the callback function. * @param {string} options.callbackId - The unique callback identifier, used to track the callback. * @param {string} options.type - The type of callback, either 'callback' for standard callbacks or 'promise' for Promise-based callbacks. * @param {function} options.fn - The callback function to register, invoked when the server responds. * - If the `type` is 'callback', this function is called when the server responds with the result. * - If the `type` is 'promise', this function acts as the `resolve` function for the promise. * @param {function} [options.onPending] - The pending callback function to invoke before timeout occurs, if provided. * @param {number} [options.pendingTimeout] - The timeout duration (in milliseconds) for triggering the pending callback, if `onPending` is provided. * @param {number} options.callbackTimeout - The timeout duration (in milliseconds) for the callback function to be triggered before it's considered a timeout. * @returns {void} */ _registerSocketCallback(options) { // Destructure the options object to extract callback-related parameters. const { callbackId, type, fn, onPending, pendingTimeout, callbackTimeout } = options; // Parameter validation: callbackTimeout must be positive integers if (!Number.isInteger(callbackTimeout) || callbackTimeout <= 0) { throw new Error( 'Invalid timeout parameters: "callbackTimeout" must be positive integers.' ); } // Parameter validation: if "pendingTimeout" is provided, it must be a positive integer if (pendingTimeout !== undefined && (!Number.isInteger(pendingTimeout) || pendingTimeout <= 0)) { throw new Error('"pendingTimeout" must be a positive integer if provided.'); } // Set the callback timeout, and invoke the appropriate error callback based on the type. const timeoutId = setTimeout(() => { if (type === 'callback') { fn({ error: `Is callback timeout: ${callbackId}`, errorCode: 'CALLBACK_TIMEOUT' }, null); } else if (type === 'promise') { fn({ success: false, error: `Is promise callback timeout: ${callbackId}`, errorCode: 'CALLBACK_TIMEOUT' }); } delete this.socketCallbacks[callbackId]; }, callbackTimeout); // If there is a pending callback and the pending timeout is less than the maximum timeout, set a pending timer. let pendingTimer = null; if (onPending && typeof onPending === 'function' && pendingTimeout < callbackTimeout) { pendingTimer = setTimeout(() => { onPending(); }, pendingTimeout); } // Store the callback, timeout ID, and pending timer in the `socketCallbacks` object. this.socketCallbacks[callbackId] = { [type === 'callback' ? 'callback' : 'resolve']: fn, timeoutId, pendingTimer, }; } /** * Generates a unique callback ID. * * The callback ID consists of: * 1. The current timestamp (in milliseconds). * 2. A randomly generated string to ensure uniqueness. * 3. An incremental counter to avoid collisions in high-frequency calls within the same millisecond. * * @returns {string} - The generated unique callback ID. */ _generateCallbackId() { this._callbackIdCounter = (this._callbackIdCounter || 0) + 1; return `${Date.now().toString(36)}-${Math.random().toString(36).substring(2, 6)}-${this._callbackIdCounter.toString(36)}`; } /** * Attempts to reconnect the WebSocket connection. * * This method first checks if the maximum reconnect attempts have been reached, if reconnection is locked, or if reconnection is forbidden. * Then, depending on the current reconnect attempt count, it uses different strategies for reconnection, including fast reconnect and exponential backoff (with jitter to avoid reconnect competition). * Finally, it starts a timer to perform the reconnection, ensuring that only one timer is active at any given time. * @param {boolean} [repeatReset] - // Whether to reset the current retry count to 0 on each reconnection attempt; default is false * * @returns {void} */ reconnect(repeatReset = false) { // Reset heartbeat timer this._heartReset(); if (repeatReset) this.repeat = 0; // If autoReconnect is disabled in the options, reconnection is locked or forbidden. exit directly. // if (!this.options.autoReconnect || this.lockReconnect || this.forbidReconnect) return; if (this.lockReconnect || this.forbidReconnect) return; // Check if the maximum reconnect attempts have been reached. if (this.options.repeatLimit !== null && typeof this.options.repeatLimit === 'number' && this.options.repeatLimit <= this.repeat) { // Trigger the listener when the maximum reconnection attempts are reached. this._executeListeners("repeat-limit", this.options.repeatLimit); return; } // If the current WebSocket instance exists, close it first if (this.ws) { // Check the current connection state and safely close the WebSocket. // Only call close() when the connection state is CONNECTING or OPEN to avoid exceptions when closing an already closed or closing connection. if ([WebSocket.CONNECTING, WebSocket.OPEN].includes(this.ws.readyState)) { // Use a custom close code (recommended range: 4000–4999) and reason string when manually closing the connection this.ws.close(4001, 'client manual close'); } // Unbind all WebSocket event listeners. // Unbinding events like onopen/onmessage/onerror/onclose prevents callbacks from firing after the connection is closed, // avoiding memory leaks or unexpected behavior. ['onopen', 'onmessage', 'onerror', 'onclose'].forEach(event => { this.ws[event] = null; }); // Manually set this.ws to null. // After closing the WebSocket connection, explicitly set this.ws to null. // This serves two purposes: // Release the reference to the old WebSocket instance to help garbage collection (GC) clean up memory. // Clearly mark that this.ws is disconnected during the reconnect cycle to avoid misoperations. this.ws = null; } // Lock the reconnection to prevent multiple reconnection timers from running simultaneously. this.lockReconnect = true; this.repeat++; let timeout; if (this.repeat <= this.options.fastReconnectThreshold) { // Fast reconnect strategy. timeout = this.options.fastReconnectInterval; } else { // Exponential backoff strategy + jitter to avoid competition when multiple clients are reconnecting. let baseTimeout = Math.pow(2, this.repeat - this.options.fastReconnectThreshold) * this.options.fastReconnectInterval; // Jitter adds random delay to avoid reconnection competition. let jitter = Math.random() * this.options.fastReconnectInterval; // Ensure the timeout does not exceed the maximum reconnect interval. timeout = Math.min(baseTimeout + jitter, this.options.reconnectMaxInterval); // Ensure timeout is not negative and greater than or equal to the minimum reconnect interval. timeout = Math.max(timeout, this.options.fastReconnectInterval); // Round timeout to ensure it is an integer (milliseconds). timeout = Math.floor(timeout); } // Trigger the reconnect event, passing the current reconnect attempt count (repeat) // and the timeout value (in milliseconds) indicating when the reconnection should actually be triggered. this._executeListeners("reconnect", { repeat: this.repeat, timeout }); // The lockReconnect flag ensures the uniqueness of reconnection, preventing multiple timers in the same reconnect cycle. // Using lockReconnect to prevent setting multiple timers, ensuring that only one timer is active during each reconnect attempt. // But it is possible to manually reconnect during the reconnection period, so a timer needs to be stored if (this.reconnectTimer) clearTimeout(this.reconnectTimer); this.reconnectTimer = setTimeout(() => { this._createWebSocket(); this.lockReconnect = false; }, timeout) } /** * Determines if the WebSocket is currently reconnecting automatically. * * If `this.repeat` is greater than 0, it indicates that the WebSocket is in the process of reconnecting. * * @returns {boolean} Returns `true` if the WebSocket is reconnecting, otherwise `false`. */ reconnecting() { return this.repeat > 0; } /** * Reset and start the heartbeat mechanism. * * This method first calls `_heartReset()` to reset the heartbeat, then calls `_heartStart()` to start the heartbeat. * * @returns {void} */ _heartCheck() { this._heartReset(); this._heartStart(); } /** * Starts the WebSocket heartbeat mechanism and periodically sends a ping message to check the connection status. * * This method sends a ping message to the server at regular intervals, and expects a pong message in return to verify the connection. * If no response is received within a specified time, it considers the connection closed and closes the WebSocket. * * @returns {void} */ _heartStart() { // If reconnection is forbidden, do not perform heartbeat. if (this.forbidReconnect) return; this.pingTimer = setTimeout(() => { // Send a ping message, the server will return a pong message to confirm the connection. this._executeListeners("ping"); this.pingGoTime = (new Date()).getTime(); if (this.ws && this.ws.readyState === WebSocket.OPEN) { this.ws.send(this.options.pingMsg); } // If no pong response is received within the specified time, the connection might be closed, and we handle it. this.pongTimer = setTimeout(() => { // Trigger the listener when pong timeout occurs. this._executeListeners("pong-timeout"); if (this.ws && this.ws.readyState === WebSocket.OPEN) { this.ws.close(4002, 'pong timeout'); } }, this.options.pongTimeout); }, this.options.pingInterval); } /** * Resets the WebSocket heartbeat status by clearing the ping and pong timeout timers. * * This method clears both the ping sending timer and the pong waiting timer. * * @returns {void} */ _heartReset() { if (this.pingTimer) clearTimeout(this.pingTimer); if (this.pongTimer) clearTimeout(this.pongTimer); } /** * Set the ping interval for the heartbeat mechanism. * @param {number} newInterval - The new ping interval in milliseconds. Must be a positive number. * @param {boolean} [immediate=false] - Whether to apply the new interval immediately by clearing the current timer. * If true, the heartbeat timer will be reset right away. */ setPingInterval(newInterval, immediate = false) { if (typeof newInterval !== 'number' || newInterval <= 0) { throw new TypeError('pingInterval must be a positive number'); } this.options.pingInterval = newInterval; if (immediate && this.pingTimer) { clearTimeout(this.pingTimer); this._heartStart(); } } /** * Get the current ping interval configuration. * * @returns {number} The ping interval in milliseconds. */ getPingInterval() { return this.options.pingInterval; } /** * Manually closes the WebSocket connection and disables the reconnection mechanism. * * After manually closing the connection, this method disables automatic reconnection, * clears heartbeat timers, and releases related resource references to speed up memory recycling. * * @returns {void} */ manualClose() { // Stop automatic reconnection after manually closing the connection. this.forbidReconnect = true; // Clear the heartbeat timer. this._heartReset(); // Clear the reconnect timer. if (this.reconnectTimer) clearTimeout(this.reconnectTimer); if (this.ws) { // Only call close if the connection is in CONNECTING or OPEN state. if ([WebSocket.CONNECTING, WebSocket.OPEN].includes(this.ws.readyState)) { // Use a custom close code (recommended range: 4000–4999) and reason string when manually closing the connection this.ws.close(4001, 'client manual close'); } // Explicitly remove all event listeners to help accelerate garbage collection. ['onopen', 'onmessage', 'onerror', 'onclose'].forEach(event => { this.ws[event] = null; }); // Release the ws reference to ensure complete disconnection and memory cleanup. this.ws = null; } } } // Check if running in a CommonJS environment (Node.js or bundlers like Webpack, Rollup) if (typeof module !== 'undefined' && module.exports) { // Export as a CommonJS module module.exports = WebSocketConnector; } else if (typeof window !== 'undefined') { // Otherwise, if running in a browser environment, // expose WebSocketConnector as a global variable on the window object window.WebSocketConnector = WebSocketConnector; }