@hpkv/websocket-client
Version:
HPKV WebSocket client for Node.js
1,317 lines (1,305 loc) • 53.2 kB
JavaScript
/**
* @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