UNPKG

@hpkv/websocket-client

Version:
1,317 lines (1,305 loc) 53.2 kB
/** * @hpkv/websocket-client v1.4.1 * HPKV WebSocket client for Node.js * @license MIT */ import { WebSocket } from 'ws'; import crossFetch from 'cross-fetch'; /** * Constants to match WebSocket states across environments */ const WS_CONSTANTS = { CONNECTING: 0, OPEN: 1, CLOSING: 2, CLOSED: 3, }; /** * Connection state for WebSocket client */ var ConnectionState; (function (ConnectionState) { ConnectionState["DISCONNECTED"] = "DISCONNECTED"; ConnectionState["CONNECTING"] = "CONNECTING"; ConnectionState["CONNECTED"] = "CONNECTED"; ConnectionState["DISCONNECTING"] = "DISCONNECTING"; ConnectionState["RECONNECTING"] = "RECONNECTING"; })(ConnectionState || (ConnectionState = {})); /** * Represents the available operations for HPKV database interactions */ var HPKVOperation; (function (HPKVOperation) { HPKVOperation[HPKVOperation["GET"] = 1] = "GET"; HPKVOperation[HPKVOperation["SET"] = 2] = "SET"; HPKVOperation[HPKVOperation["PATCH"] = 3] = "PATCH"; HPKVOperation[HPKVOperation["DELETE"] = 4] = "DELETE"; HPKVOperation[HPKVOperation["RANGE"] = 5] = "RANGE"; HPKVOperation[HPKVOperation["ATOMIC"] = 6] = "ATOMIC"; })(HPKVOperation || (HPKVOperation = {})); class HPKVError extends Error { constructor(message, code) { super(message); this.code = code; this.name = 'HPKVError'; } } class ConnectionError extends HPKVError { constructor(message) { super(message); this.name = 'ConnectionError'; } } class TimeoutError extends HPKVError { constructor(message) { super(message); this.name = 'TimeoutError'; } } class AuthenticationError extends HPKVError { constructor(message) { super(message); this.name = 'AuthenticationError'; } } /** * SimpleEventEmitter * * A lightweight implementation of the EventEmitter pattern. * Provides methods to register event listeners, emit events, and manage subscriptions. */ class SimpleEventEmitter { constructor() { this.events = {}; } /** * Register an event listener for the specified event * * @param event - The event name to listen for * @param listener - The callback function to execute when the event is emitted * @returns The emitter instance for chaining */ on(event, listener) { if (!this.events[event]) { this.events[event] = []; } this.events[event].push(listener); return this; } /** * Remove a previously registered event listener * * @param event - The event name * @param listener - The callback function to remove * @returns The emitter instance for chaining */ off(event, listener) { if (this.events[event]) { this.events[event] = this.events[event].filter(l => l !== listener); } return this; } /** * Emit an event with the specified arguments * * @param event - The event name to emit * @param args - Arguments to pass to the event listeners * @returns `true` if the event had listeners, `false` otherwise */ emit(event, ...args) { if (this.events[event]) { this.events[event].forEach(listener => listener(...args)); return true; } return false; } } /** * Creates a WebSocket instance that works with Node.js or browser environments * @param url - The WebSocket URL to connect to * @returns A WebSocket instance with normalized interface */ function createWebSocket(url) { let browserWebSocketConstructor = null; if (typeof window !== 'undefined' && typeof window.WebSocket === 'function') { browserWebSocketConstructor = window.WebSocket; } else if (typeof self !== 'undefined' && typeof self.WebSocket === 'function') { // Check for Web Worker environments or other self-scoped environments browserWebSocketConstructor = self.WebSocket; } else if (typeof global !== 'undefined' && typeof global.WebSocket === 'function') { browserWebSocketConstructor = global.WebSocket; } if (browserWebSocketConstructor) { return createBrowserWebSocket(url, browserWebSocketConstructor); } else if (typeof WebSocket !== 'undefined') { return createNodeWebSocket(url); } else { throw new HPKVError('No suitable WebSocket implementation found.'); } } /** * Creates a WebSocket instance for browser environments */ function createBrowserWebSocket(url, WebSocketClass) { const ws = new WebSocketClass(url); // Store pairs of { original: Function, wrapper: Function } const internalListeners = {}; return { get readyState() { return ws.readyState; }, on(event, listener) { let eventHandler; switch (event) { case 'open': eventHandler = (_nativeEvent) => listener(); break; case 'message': eventHandler = (nativeEvent) => { try { const data = typeof nativeEvent.data === 'string' ? JSON.parse(nativeEvent.data) : nativeEvent.data; listener(data); } catch (e) { if (e instanceof SyntaxError) { listener(nativeEvent.data); // Fallback with raw data } else { console.error('[HPKV Websocket Client] createBrowserWebSocket: Error processing "message" or in listener callback:', e); } } }; break; case 'close': eventHandler = (nativeEvent) => listener(nativeEvent === null || nativeEvent === void 0 ? void 0 : nativeEvent.code, nativeEvent === null || nativeEvent === void 0 ? void 0 : nativeEvent.reason); break; case 'error': eventHandler = (nativeEvent) => listener(nativeEvent); break; default: throw new Error(`[HPKV Websocket Client] createBrowserWebSocket: Attaching direct listener for unhandled event type "${event}"`); } ws.addEventListener(event, eventHandler); internalListeners[event] = internalListeners[event] || []; internalListeners[event].push({ original: listener, wrapper: eventHandler }); return this; }, removeAllListeners() { Object.keys(internalListeners).forEach(event => { internalListeners[event].forEach(listenerPair => { ws.removeEventListener(event, listenerPair.wrapper); }); delete internalListeners[event]; }); return this; }, removeListener(event, listener) { if (!internalListeners[event]) { return this; } const listenerIndex = internalListeners[event].findIndex(listenerPair => listenerPair.original === listener); if (listenerIndex !== -1) { const { wrapper } = internalListeners[event][listenerIndex]; ws.removeEventListener(event, wrapper); internalListeners[event].splice(listenerIndex, 1); if (internalListeners[event].length === 0) { delete internalListeners[event]; } } return this; }, send(data) { ws.send(data); }, close(code, reason) { ws.close(code, reason); }, }; } /** * Creates a WebSocket instance for Node.js environments */ function createNodeWebSocket(url) { try { const ws = new WebSocket(url); const internalListeners = {}; return { get readyState() { return ws.readyState; }, on(event, listener) { let nodeEventHandler; switch (event) { case 'open': nodeEventHandler = () => listener(); break; case 'message': nodeEventHandler = (rawData) => { try { let jsonData; if (typeof rawData === 'object' && !Buffer.isBuffer(rawData)) { jsonData = rawData; } else if (Buffer.isBuffer(rawData) || typeof rawData === 'string') { const stringData = Buffer.isBuffer(rawData) ? rawData.toString('utf8') : rawData; jsonData = JSON.parse(stringData); } else { listener(rawData); return; } if ('type' in jsonData && jsonData.type === 'notification') { listener(jsonData); } else { listener(jsonData); } } catch (e) { if (e instanceof SyntaxError) { if (Buffer.isBuffer(rawData)) { listener(rawData.toString('utf8')); } else { listener(rawData); } } else { throw e; } } }; break; case 'close': nodeEventHandler = (code, reasonBuffer) => { const reason = reasonBuffer ? reasonBuffer.toString('utf8') : ''; listener(code, reason); }; break; case 'error': nodeEventHandler = (error) => listener(error); break; default: throw new HPKVError(`Attaching direct listener for unhandled websocket event type "${event}"`); } ws.on(event, nodeEventHandler); internalListeners[event] = internalListeners[event] || []; internalListeners[event].push({ original: listener, wrapper: nodeEventHandler }); return this; }, removeAllListeners() { ws.removeAllListeners(); Object.keys(internalListeners).forEach(event => { delete internalListeners[event]; }); return this; }, removeListener(event, listener) { if (!internalListeners[event]) { return this; } const listenerIndex = internalListeners[event].findIndex(listenerPair => listenerPair.original === listener); if (listenerIndex !== -1) { const { wrapper } = internalListeners[event][listenerIndex]; ws.removeListener(event, wrapper); internalListeners[event].splice(listenerIndex, 1); if (internalListeners[event].length === 0) { delete internalListeners[event]; } } return this; }, send(data) { ws.send(data); }, close(code, reason) { ws.close(code, reason); }, }; } catch (error) { throw new ConnectionError(`Failed to initialize WebSocket for Node.js: ${error instanceof Error ? error.message : String(error)}`); } } /** * Defines default timeout values in milliseconds */ const DEFAULT_TIMEOUTS = { OPERATION: 10000, CLEANUP: 60000, }; /** * Manages WebSocket message handling and pending requests */ class MessageHandler { /** * Creates a new MessageHandler * @param timeouts - Optional custom timeout values */ constructor(timeouts) { this.messageId = 0; this.messageMap = new Map(); this.timeouts = { ...DEFAULT_TIMEOUTS }; this.cleanupInterval = null; /** * Callback to be invoked when a rate limit error (e.g., 429) is detected. * The consumer (e.g., BaseWebSocketClient) can set this to react accordingly. */ this.onRateLimitExceeded = null; if (timeouts) { this.timeouts = { ...this.timeouts, ...timeouts }; } this.initCleanupInterval(); } /** * Initializes the cleanup interval for stale requests */ initCleanupInterval() { this.clearCleanupInterval(); this.cleanupInterval = setInterval(() => this.cleanupStaleRequests(), this.timeouts.CLEANUP); } /** * Clears the cleanup interval */ clearCleanupInterval() { if (this.cleanupInterval !== null) { clearInterval(this.cleanupInterval); this.cleanupInterval = null; } } /** * Gets the next message ID, ensuring it doesn't overflow * @returns A safe message ID number */ getNextMessageId() { if (this.messageId >= Number.MAX_SAFE_INTEGER - 1000) { this.messageId = 0; } return ++this.messageId; } /** * Creates a message with an assigned ID * @param message - Base message without ID * @returns Message with ID */ createMessage(message) { const id = this.getNextMessageId(); return { ...message, messageId: id, }; } /** * Registers a pending request * @param messageId - The ID of the message * @param operation - The operation being performed * @param timeoutMs - Optional custom timeout for this operation * @returns A promise and cleanup functions */ registerRequest(messageId, operation, timeoutMs) { const actualTimeoutMs = timeoutMs || this.timeouts.OPERATION; let resolve; let reject; const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); const timer = setTimeout(() => { if (this.messageMap.has(messageId)) { this.messageMap.delete(messageId); reject(new TimeoutError(`Operation timed out after ${actualTimeoutMs}ms: ${operation}`)); } }, actualTimeoutMs); this.messageMap.set(messageId, { resolve, reject, timer, timestamp: Date.now(), operation, }); const cancel = (reason) => { if (this.messageMap.has(messageId)) { clearTimeout(timer); this.messageMap.delete(messageId); reject(new Error(reason)); } }; return { promise, cancel }; } /** * Processes an incoming WebSocket message * @param message - The message received from the WebSocket server * @returns True if the message was handled, false if no matching request was found */ handleMessage(message) { const baseMessage = message; const messageId = baseMessage.messageId; if (!messageId) { return; } const pendingRequest = this.messageMap.get(messageId); if (!pendingRequest) { // This might happen if a request timed out but the server still responded return; } if (baseMessage.code === 200) { pendingRequest.resolve(message); } else { Promise.resolve().then(() => { var _a; if ('code' in message && message.code === 429) { (_a = this.onRateLimitExceeded) === null || _a === void 0 ? void 0 : _a.call(this, message); } }); pendingRequest.reject(new HPKVError(baseMessage.error || baseMessage.message || 'Unknown error', baseMessage.code || 500)); } clearTimeout(pendingRequest.timer); this.messageMap.delete(messageId); } /** * Type guard to check if a response is a notification */ isNotification(message) { return 'type' in message && message.type === 'notification'; } /** * Type guard to check if a response is an error response */ isErrorResponse(message) { return ('code' in message && message.code !== 200) || 'error' in message; } /** * Cancels all pending requests with the given error * @param error - The error to reject pending requests with */ cancelAllRequests(error) { for (const [id, request] of this.messageMap.entries()) { clearTimeout(request.timer); request.reject(error); this.messageMap.delete(id); } } /** * Removes stale requests that have been pending for too long */ cleanupStaleRequests() { const now = Date.now(); const staleThreshold = this.timeouts.OPERATION * 3; for (const [id, request] of this.messageMap.entries()) { const age = now - request.timestamp; if (age > staleThreshold) { clearTimeout(request.timer); request.reject(new TimeoutError(`Request ${id} (${request.operation}) timed out after ${age}ms`)); this.messageMap.delete(id); } } } /** * Gets the number of pending requests */ get pendingCount() { return this.messageMap.size; } /** * Destroys this handler and cleans up resources */ destroy() { this.cancelAllRequests(new Error('Handler destroyed')); this.clearCleanupInterval(); } } /** * Default throttling configuration */ const DEFAULT_THROTTLING = { enabled: true, rateLimit: 10, }; /** * Manages throttling of requests based on RTT and 429 errors */ class ThrottlingManager { constructor(config) { this.throttleQueue = []; this.processingQueue = false; this.nextAvailableSlotTime = 0; this.backoffUntil = 0; this.backoffExponent = 0; this.throttlingConfig = { ...DEFAULT_THROTTLING, ...(config || {}), }; this.currentRate = this.throttlingConfig.rateLimit || DEFAULT_THROTTLING.rateLimit; } /** * Returns current throttling configuration */ get config() { return { ...this.throttlingConfig }; } /** * Returns current throttling metrics */ getMetrics() { return { currentRate: this.currentRate, queueLength: this.throttleQueue.length, }; } /** * Updates the throttling configuration */ updateConfig(config) { const wasEnabled = this.throttlingConfig.enabled; this.throttlingConfig = { ...this.throttlingConfig, ...config, }; this.currentRate = this.throttlingConfig.rateLimit || DEFAULT_THROTTLING.rateLimit; if (wasEnabled && !this.throttlingConfig.enabled) { while (this.throttleQueue.length > 0) { const next = this.throttleQueue.shift(); if (next) next(); } } } /** * Notifies the throttler of a 429 error to apply backpressure */ notify429() { if (Date.now() < this.backoffUntil) return; this.currentRate = Math.max((this.throttlingConfig.rateLimit || DEFAULT_THROTTLING.rateLimit) * 0.1, this.currentRate * 0.5); const backoffDelay = 1000 * Math.min(60, 2 ** this.backoffExponent); this.backoffUntil = Date.now() + backoffDelay; this.backoffExponent++; } /** * Adds a request to the throttle queue if needed, or executes immediately. */ async throttleRequest() { if (!this.throttlingConfig.enabled) { return Promise.resolve(); } const now = Date.now(); const minTimeBetweenRequests = 1000 / this.currentRate; const earliestRunTime = Math.max(now, this.nextAvailableSlotTime, this.backoffUntil); if (earliestRunTime <= now) { this.nextAvailableSlotTime = now + minTimeBetweenRequests; return Promise.resolve(); } else { return new Promise(resolve => { this.throttleQueue.push(resolve); if (!this.processingQueue) { this.processThrottleQueue(); } }); } } /** * Processes the throttle queue based on the current rate */ processThrottleQueue() { if (this.throttleQueue.length === 0) { this.processingQueue = false; return; } this.processingQueue = true; const now = Date.now(); this.nextAvailableSlotTime = Math.max(now, this.nextAvailableSlotTime); if (now < this.backoffUntil) { this.nextAvailableSlotTime = Math.max(this.nextAvailableSlotTime, this.backoffUntil); const backoffWait = this.nextAvailableSlotTime - now; setTimeout(() => this.processThrottleQueue(), backoffWait); return; } const minTimeBetweenRequests = 1000 / this.currentRate; const timeToWait = this.nextAvailableSlotTime - now; setTimeout(() => { const next = this.throttleQueue.shift(); if (next) { next(); } if (this.throttleQueue.length > 0) { this.processThrottleQueue(); } else { this.processingQueue = false; } }, timeToWait); this.nextAvailableSlotTime += minTimeBetweenRequests; } /** * Cleans up resources */ destroy() { while (this.throttleQueue.length > 0) { const next = this.throttleQueue.shift(); if (next) next(); } } } const CLEANUP_CONFIRMATION_TIMEOUT_MS = 200; const DEFAULT_CONNECTION_TIMEOUT_MS = 5000; /** * Base WebSocket client that handles connection management and message passing * for the HPKV WebSocket API. */ class BaseWebSocketClient { /** * Creates a new BaseWebSocketClient instance * @param baseUrl - The base URL of the HPKV API * @param config - The connection configuration including timeouts and retry options */ constructor(baseUrl, config) { this.ws = null; this.connectionPromise = null; this.connectionState = ConnectionState.DISCONNECTED; this.reconnectAttempts = 0; this.connectionTimeout = null; this.emitter = new SimpleEventEmitter(); this.isGracefulDisconnect = false; let processedBaseUrl = baseUrl.replace(/^http:\/\//, 'ws://').replace(/^https:\/\//, 'wss://'); if (!processedBaseUrl.endsWith('/ws')) { processedBaseUrl += '/ws'; } this.baseUrl = processedBaseUrl; this.retry = { maxReconnectAttempts: (config === null || config === void 0 ? void 0 : config.maxReconnectAttempts) || 3, initialDelayBetweenReconnects: (config === null || config === void 0 ? void 0 : config.initialDelayBetweenReconnects) || 1000, maxDelayBetweenReconnects: (config === null || config === void 0 ? void 0 : config.maxDelayBetweenReconnects) || 30000, jitterMs: 500, }; this.connectionTimeoutDuration = (config === null || config === void 0 ? void 0 : config.connectionTimeout) || DEFAULT_CONNECTION_TIMEOUT_MS; this.messageHandler = new MessageHandler(); this.throttlingManager = new ThrottlingManager(config === null || config === void 0 ? void 0 : config.throttling); this.messageHandler.onRateLimitExceeded = (_error) => { this.throttlingManager.notify429(); }; } // Public API Methods /** * Retrieves a value from the key-value store * @param key - The key to retrieve * @param timeoutMs - Optional custom timeout for this operation in milliseconds * @returns A promise that resolves with the API response * @throws Error if the key is not found or connection fails */ async get(key, timeoutMs) { return this.sendMessage({ op: HPKVOperation.GET, key, }, timeoutMs); } /** * Stores a value in the key-value store * @param key - The key to store the value under * @param value - The value to store (will be stringified if not a string) * @param partialUpdate - If true, performs a partial update/patch instead of replacing the entire value * @param timeoutMs - Optional custom timeout for this operation in milliseconds * @returns A promise that resolves with the API response * @throws Error if the operation fails or connection is lost */ async set(key, value, partialUpdate = false, timeoutMs) { const stringValue = typeof value === 'string' ? value : JSON.stringify(value); const operation = partialUpdate ? HPKVOperation.PATCH : HPKVOperation.SET; return this.sendMessage({ op: operation, key, value: stringValue, }, timeoutMs); } /** * Deletes a value from the key-value store * @param key - The key to delete * @param timeoutMs - Optional custom timeout for this operation in milliseconds * @returns A promise that resolves with the API response * @throws Error if the key is not found or connection fails */ async delete(key, timeoutMs) { return this.sendMessage({ op: HPKVOperation.DELETE, key, }, timeoutMs); } /** * Performs a range query to retrieve multiple keys within a specified range * @param key - The start key of the range * @param endKey - The end key of the range * @param options - Additional options for the range query including result limit * @param timeoutMs - Optional custom timeout for this operation in milliseconds * @returns A promise that resolves with the API response containing matching records * @throws Error if the operation fails or connection is lost */ async range(key, endKey, options, timeoutMs) { return this.sendMessage({ op: HPKVOperation.RANGE, key, endKey, limit: options === null || options === void 0 ? void 0 : options.limit, }, timeoutMs); } /** * Performs an atomic increment operation on a numeric value * @param key - The key of the value to increment * @param value - The amount to increment by * @param timeoutMs - Optional custom timeout for this operation in milliseconds * @returns A promise that resolves with the API response * @throws Error if the key does not contain a numeric value or connection fails */ async atomicIncrement(key, value, timeoutMs) { return this.sendMessage({ op: HPKVOperation.ATOMIC, key, value, }, timeoutMs); } // Connection Lifecycle Methods /** * Establishes a WebSocket connection to the HPKV API * @returns A promise that resolves when the connection is established * @throws ConnectionError if the connection fails or times out */ async connect() { this.isGracefulDisconnect = false; this.syncConnectionState(); if (this.connectionState === ConnectionState.CONNECTED && this.isWebSocketOpen()) { return; } if ((this.connectionState === ConnectionState.CONNECTING || this.connectionState === ConnectionState.RECONNECTING) && this.connectionPromise) { return this.connectionPromise; } this.connectionState = ConnectionState.CONNECTING; this.connectionPromise = new Promise((resolve, reject) => this._initiateConnectionAttempt(resolve, reject)); return this.connectionPromise; } _initiateConnectionAttempt(resolve, reject) { if (this.connectionTimeout) { clearTimeout(this.connectionTimeout); this.connectionTimeout = null; } this.connectionTimeout = setTimeout(() => { if (this.connectionState !== ConnectionState.CONNECTED) { if (this.ws) { const currentWs = this.ws; currentWs.removeListener('open', onOpen); currentWs.removeListener('error', onErrorDuringConnect); currentWs.removeListener('close', onCloseDuringConnect); // Only nullify if it's the instance from this attempt and not yet closed/reassigned if (this.ws === currentWs) { currentWs.removeAllListeners(); this.ws = null; } } this.connectionState = ConnectionState.DISCONNECTED; const timeoutError = new TimeoutError(`Connection timeout after ${this.connectionTimeoutDuration}ms (client-side)`); this.emitter.emit('error', timeoutError); reject(timeoutError); } }, this.connectionTimeoutDuration); let wsInstance; try { const urlToConnect = this.buildConnectionUrl(); wsInstance = createWebSocket(urlToConnect); this.ws = wsInstance; } catch (error) { if (this.connectionTimeout) { clearTimeout(this.connectionTimeout); this.connectionTimeout = null; } this.connectionState = ConnectionState.DISCONNECTED; const errorMessage = error instanceof Error ? error.message : 'Unknown error creating WebSocket'; const connError = new ConnectionError(errorMessage); this.emitter.emit('error', connError); reject(connError); return; } const removeConnectAttemptListeners = () => { if (wsInstance) { wsInstance.removeListener('open', onOpen); wsInstance.removeListener('error', onErrorDuringConnect); wsInstance.removeListener('close', onCloseDuringConnect); } }; const onOpen = () => { removeConnectAttemptListeners(); if (this.connectionTimeout) { clearTimeout(this.connectionTimeout); this.connectionTimeout = null; } this.connectionState = ConnectionState.CONNECTED; this.emitter.emit('connected'); this.reconnectAttempts = 0; if (this.ws === wsInstance) { // Setting up persistent listeners on the correct instance this.ws.on('message', (data) => this.handleMessage(data)); this.ws.on('error', (err) => this.handleWebSocketError(err)); this.ws.on('close', (code, reason) => this.handleWebSocketClose(code, reason)); } resolve(); }; const onErrorDuringConnect = (error) => { if (this.connectionState !== ConnectionState.CONNECTING) { removeConnectAttemptListeners(); return; } removeConnectAttemptListeners(); if (this.connectionTimeout) { clearTimeout(this.connectionTimeout); this.connectionTimeout = null; } this.connectionState = ConnectionState.DISCONNECTED; if (this.ws === wsInstance) { this.ws = null; } const connError = new ConnectionError(`WebSocket connection error during connect: ${error.message || 'Unknown WebSocket Error'}`); this.emitter.emit('error', connError); reject(connError); }; const onCloseDuringConnect = (code, reason) => { if (this.connectionState !== ConnectionState.CONNECTING) { removeConnectAttemptListeners(); return; } removeConnectAttemptListeners(); if (this.connectionTimeout) { clearTimeout(this.connectionTimeout); this.connectionTimeout = null; } this.connectionState = ConnectionState.DISCONNECTED; if (this.ws === wsInstance) { // If this.ws still refers to the instance that closed this.ws = null; } const connError = new ConnectionError(`WebSocket closed before opening (code: ${code !== null && code !== void 0 ? code : 'N/A'}, reason: ${reason !== null && reason !== void 0 ? reason : 'N/A'})`); this.emitter.emit('error', connError); reject(connError); }; wsInstance.on('open', onOpen); wsInstance.on('error', onErrorDuringConnect); wsInstance.on('close', onCloseDuringConnect); } /** * Gracefully closes the WebSocket connection * @param cancelPendingRequests - Whether to cancel all pending requests (default: true) * @returns A promise that resolves when the connection is closed and cleaned up */ async disconnect(cancelPendingRequests = true) { this.isGracefulDisconnect = true; this.reconnectAttempts = 0; // Prevent reconnections during/after graceful disconnect this.syncConnectionState(); if (this.connectionState === ConnectionState.DISCONNECTED && !this.ws) { this.isGracefulDisconnect = false; return Promise.resolve(); } this.connectionState = ConnectionState.DISCONNECTING; if (cancelPendingRequests) { this.messageHandler.cancelAllRequests(new ConnectionError('Connection closed by client')); } return this.cleanup(1000, 'Normal closure by client').finally(() => { this.connectionState = ConnectionState.DISCONNECTED; this.connectionPromise = null; if (this.isGracefulDisconnect) { // Only reset if this was the one setting it. this.isGracefulDisconnect = false; } }); } /** * Attempts to reconnect to the WebSocket server with exponential backoff * @returns A promise that resolves when the connection is reestablished * @throws ConnectionError if reconnection fails after max attempts */ async reconnect() { this.reconnectAttempts++; this.connectionState = ConnectionState.RECONNECTING; const baseDelay = Math.min(this.retry.initialDelayBetweenReconnects * Math.pow(2, this.reconnectAttempts - 1), this.retry.maxDelayBetweenReconnects); const jitter = this.retry.jitterMs ? Math.floor(Math.random() * this.retry.jitterMs) : 0; const delay = baseDelay + jitter; this.emitter.emit('reconnecting', { attempt: this.reconnectAttempts, maxAttempts: this.retry.maxReconnectAttempts, delay, }); await new Promise(resolve => setTimeout(resolve, delay)); try { await this.connect(); this.emitter.emit('connected'); } catch (error) { if (this.reconnectAttempts < this.retry.maxReconnectAttempts) { return this.reconnect(); } else { this.connectionState = ConnectionState.DISCONNECTED; const connectionError = new ConnectionError(error instanceof Error ? `Reconnection failed after ${this.retry.maxReconnectAttempts} attempts: ${error.message}` : `Reconnection failed after ${this.retry.maxReconnectAttempts} attempts`); this.emitter.emit('reconnectFailed', connectionError); this.messageHandler.cancelAllRequests(connectionError); throw connectionError; } } } // Connection State & Info /** * Get the current connection state * @returns The current connection state */ getConnectionState() { this.syncConnectionState(); return this.connectionState; } /** * Get current connection statistics * @returns Statistics about the current connection */ getConnectionStats() { this.syncConnectionState(); const throttlingMetrics = this.throttlingManager.getMetrics(); return { isConnected: this.connectionState === ConnectionState.CONNECTED, reconnectAttempts: this.reconnectAttempts, messagesPending: this.messageHandler.pendingCount, connectionState: this.connectionState, throttling: this.throttlingManager.config.enabled ? { currentRate: throttlingMetrics.currentRate, queueLength: throttlingMetrics.queueLength, } : null, }; } /** * Checks if the WebSocket connection is currently established and open * @returns True if the connection is established and ready */ isWebSocketOpen() { return this.ws !== null && this.ws.readyState === WS_CONSTANTS.OPEN; } /** * Updates the connection state based on WebSocket readyState * to ensure consistency between the two state tracking mechanisms */ syncConnectionState() { if (!this.ws) { this.connectionState = ConnectionState.DISCONNECTED; return; } switch (this.ws.readyState) { case WS_CONSTANTS.CONNECTING: this.connectionState = ConnectionState.CONNECTING; break; case WS_CONSTANTS.OPEN: this.connectionState = ConnectionState.CONNECTED; break; case WS_CONSTANTS.CLOSING: this.connectionState = ConnectionState.DISCONNECTING; break; case WS_CONSTANTS.CLOSED: this.connectionState = ConnectionState.DISCONNECTED; break; } } // Event Emitter Methods /** * Register event listeners * @param event - The event to listen for * @param listener - The callback function to execute when the event is emitted */ on(event, listener) { this.emitter.on(event, listener); } /** * Remove event listeners * @param event - The event to stop listening for * @param listener - The callback function to remove */ off(event, listener) { this.emitter.off(event, listener); } // Throttling Management /** * Gets current throttling settings and metrics * @returns Current throttling configuration and metrics */ getThrottlingStatus() { return { enabled: this.throttlingManager.config.enabled, config: this.throttlingManager.config, metrics: this.throttlingManager.getMetrics(), }; } /** * Updates throttling configuration * @param config - New throttling configuration parameters */ updateThrottlingConfig(config) { this.throttlingManager.updateConfig(config); } // Protected Core Logic /** * Sends a message to the WebSocket server and handles the response * @param message - The message to send * @param timeoutMs - Optional custom timeout for this operation in milliseconds * @returns A promise that resolves with the server response * @throws Error if the message times out or connection fails */ async sendMessage(message, timeoutMs) { await this.throttlingManager.throttleRequest(); this.syncConnectionState(); const messageWithId = this.messageHandler.createMessage(message); const { promise, cancel } = this.messageHandler.registerRequest(messageWithId.messageId, message.op.toString(), timeoutMs || undefined); try { if (this.ws && this.isWebSocketOpen()) { this.ws.send(JSON.stringify(messageWithId)); } else { cancel('WebSocket is not open'); } } catch (error) { cancel(`Failed to send message: ${error instanceof Error ? error.message : 'Unknown error'}`); } return promise; } /** * Processes WebSocket messages and resolves corresponding promises * @param message - The message received from the WebSocket server * @returns True if the message was handled, false if no matching request was found */ handleMessage(message) { this.messageHandler.handleMessage(message); } // Protected WebSocket Event Handlers /** * Persistent handler for WebSocket 'error' events. */ handleWebSocketError(error) { // An error event on the WebSocket usually precedes a 'close' event. // Log the error and emit an event. The 'close' event will handle state changes and reconnections. const connectionError = new ConnectionError(error.message || 'Unknown WebSocket error occurred'); this.emitter.emit('error', connectionError); } /** * Persistent handler for WebSocket 'close' events. */ handleWebSocketClose(code, reason) { const wasConnected = this.connectionState === ConnectionState.CONNECTED; const previousState = this.connectionState; if (this.ws) { this.ws.removeAllListeners(); this.ws = null; } this.connectionState = ConnectionState.DISCONNECTED; this.connectionPromise = null; this.emitter.emit('disconnected', { code, reason, previousState, gracefully: this.isGracefulDisconnect, }); if (!this.isGracefulDisconnect) { this.messageHandler.cancelAllRequests(new ConnectionError(`Connection closed unexpectedly (code: ${code !== null && code !== void 0 ? code : 'N/A'}, reason: ${reason !== null && reason !== void 0 ? reason : 'N/A'})`)); if (code !== 1000 || (code === 1000 && wasConnected)) { this.initiateReconnectionCycle(); } } else { this.isGracefulDisconnect = false; } } /** * Cleans up resources and closes the WebSocket connection. */ async cleanup(code, reason) { if (this.connectionTimeout) { clearTimeout(this.connectionTimeout); this.connectionTimeout = null; } if (!this.ws) { this.connectionState = ConnectionState.DISCONNECTED; this.connectionPromise = null; return Promise.resolve(); } return new Promise(resolve => { const wsInstanceToCleanup = this.ws; let timeoutId = null; let finalized = false; const finalizeCleanup = () => { if (finalized) return; finalized = true; if (timeoutId) { clearTimeout(timeoutId); timeoutId = null; } wsInstanceToCleanup.removeListener('close', onCloseHandlerForCleanup); if (this.ws === wsInstanceToCleanup) { if (wsInstanceToCleanup.readyState !== WS_CONSTANTS.CLOSED) { wsInstanceToCleanup.removeAllListeners(); } this.ws = null; } this.connectionState = ConnectionState.DISCONNECTED; this.connectionPromise = null; resolve(); }; const onCloseHandlerForCleanup = () => { finalizeCleanup(); }; if (wsInstanceToCleanup.readyState === WS_CONSTANTS.CLOSED) { finalizeCleanup(); return; } wsInstanceToCleanup.on('close', onCloseHandlerForCleanup); if (wsInstanceToCleanup.readyState === WS_CONSTANTS.OPEN || wsInstanceToCleanup.readyState === WS_CONSTANTS.CONNECTING) { wsInstanceToCleanup.close(code, reason); } // Fallback timeout for this cleanup operation. timeoutId = setTimeout(() => { finalizeCleanup(); }, CLEANUP_CONFIRMATION_TIMEOUT_MS); }); } /** * Handles unexpected disconnect events and decides whether to initiate reconnection. * This method is called by handleWebSocketClose. */ initiateReconnectionCycle() { if (this.isGracefulDisconnect) { if (this.connectionState !== ConnectionState.DISCONNECTED) { this.connectionState = ConnectionState.DISCONNECTED; } return; } if (this.connectionState === ConnectionState.RECONNECTING || this.connectionState === ConnectionState.CONNECTING) { return; } this.connectionState = ConnectionState.RECONNECTING; this.reconnectAttempts = 0; this.reconnect().catch(_finalError => { if (this.connectionState !== ConnectionState.DISCONNECTED) { this.connectionState = ConnectionState.DISCONNECTED; } }); } /** * Clean up resources when instance is no longer needed */ destroy() { this.messageHandler.cancelAllRequests(new ConnectionError('Client destroyed')); this.messageHandler.destroy(); this.throttlingManager.destroy(); } } /** * Client for performing CRUD operations on the key-value store * Uses API key authentication for secure access to the HPKV API */ class HPKVApiClient extends BaseWebSocketClient { /** * Creates a new HPKVApiClient instance * @param apiKey - The API key to use for authentication * @param baseUrl - The base URL of the HPKV API * @param config - The configuration for the client */ constructor(apiKey, baseUrl, config) { super(baseUrl, config); this.apiKey = apiKey; } /** * Builds the WebSocket connection URL with API key authentication * @returns The WebSocket connection URL with the API key as a query parameter */ buildConnectionUrl() { return `${this.baseUrl}?apiKey=${this.apiKey}`; } } /** * Client for subscribing to real-time updates on key changes * This client uses a token for authentication and manages subscriptions * to specific keys, invoking callbacks when changes occur. */ class HPKVSubscriptionClient extends BaseWebSocketClient { /** * Creates a new HPKVSubscriptionClient instance * @param token - The authentication token to use for WebSocket connections * @param baseUrl - The base URL of the HPKV API * @param config - The connection configuration */ constructor(token, baseUrl, config) { super(baseUrl, config); this.subscriptions = new Map(); this.token = token; } /** * Builds the WebSocket connection URL with token-based authentication * @returns The WebSocket connection URL with the token as a query parameter */ buildConnectionUrl() { // Base URL already includes /ws from BaseWebSocketClient constructor return `${this.baseUrl}?token=${this.token}`; } /** * Subscribes to changes for subscribedKeys * When changes to the key occur, the provided callback will be invoked * with the update data * * @param callback - Function to be called when the key changes * @returns The callback ID */ subscribe(callback) { const callbackId = Math.random().toString(36).substring(2, 15); this.subscriptions.set(callbackId, callback); return callbackId; } /** * Unsubscribes a callback from the subscription client * * @param callbackId - The callback ID to unsubscribe */ unsubscribe(callbackId) { this.subscriptions.delete(callbackId); } /** * Processes WebSocket messages and triggers subscription callbacks * Extends the base class implementation to handle subscription events * * @param message - The message received from the WebSocket server */ handleMessage(message) { if (message && 'type' in message && message.type === 'notification') { const notification = message; if (this.subscriptions.size > 0) { this.subscriptions.forEach(callback => { Promise.resolve().then(() => { callback(notification); }); }); } } else { return super.handleMessage(message); } } } class HPKVClientFactory { /** * Creates a client for server-side operations using an API