@varia-bly/variably-sdk
Version:
Official JavaScript/TypeScript SDK for Variably feature flags, experimentation, and real-time dynamic configurations
437 lines • 15.5 kB
JavaScript
"use strict";
/**
* WebSocket client for real-time configuration updates
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.WebSocketClient = void 0;
class WebSocketClient {
constructor(config, logger) {
this.ws = null;
this.subscriptions = new Map();
this.configCallbacks = new Map();
this.connectionStateCallbacks = new Set();
this.errorCallbacks = new Set();
// Connection management
this.connectionState = 'disconnected';
this.reconnectAttempts = 0;
this.reconnectTimer = null;
this.connectionTimer = null;
this.heartbeatTimer = null;
this.lastPong = Date.now();
this.config = this.normalizeConfig(config);
this.logger = logger;
}
/**
* Connect to WebSocket server
*/
async connect() {
if (this.connectionState === 'connected' || this.connectionState === 'connecting') {
return;
}
return new Promise((resolve, reject) => {
try {
this.setConnectionState('connecting');
// Create WebSocket URL with JWT token
const wsUrl = this.config.baseUrl
.replace(/^http/, 'ws')
.replace(/\/$/, '');
const url = `${wsUrl}/api/v1/ws`;
this.logger.debug('Connecting to WebSocket', { url });
this.ws = new WebSocket(url);
// Connection timeout
this.connectionTimer = setTimeout(() => {
if (this.connectionState === 'connecting') {
this.logger.error('WebSocket connection timeout');
this.ws?.close();
reject(new Error('Connection timeout'));
}
}, this.config.connectionTimeout);
this.ws.onopen = () => {
this.clearConnectionTimer();
this.reconnectAttempts = 0;
this.setConnectionState('connected');
this.startHeartbeat();
this.resubscribeAll();
this.logger.info('WebSocket connected successfully');
resolve();
};
this.ws.onmessage = (event) => {
this.handleMessage(event.data);
};
this.ws.onclose = (event) => {
this.clearConnectionTimer();
this.clearHeartbeat();
this.logger.warn('WebSocket connection closed', {
code: event.code,
reason: event.reason,
wasClean: event.wasClean
});
this.setConnectionState('disconnected');
if (this.config.autoReconnect && this.reconnectAttempts < this.config.maxReconnectAttempts) {
this.scheduleReconnect();
}
};
this.ws.onerror = (event) => {
this.clearConnectionTimer();
this.logger.error('WebSocket error', { event });
this.setConnectionState('error');
const error = new Error('WebSocket connection error');
this.notifyError(error);
if (this.connectionState === 'connecting') {
reject(error);
}
};
}
catch (error) {
this.setConnectionState('error');
const wsError = error instanceof Error ? error : new Error('Unknown WebSocket error');
this.logger.error('Failed to create WebSocket connection', { error: wsError.message });
reject(wsError);
}
});
}
/**
* Disconnect from WebSocket server
*/
disconnect() {
this.config.autoReconnect = false;
this.clearReconnectTimer();
this.clearConnectionTimer();
this.clearHeartbeat();
if (this.ws) {
this.ws.close(1000, 'Client initiated disconnect');
this.ws = null;
}
this.setConnectionState('disconnected');
this.logger.info('WebSocket disconnected');
}
/**
* Subscribe to configuration updates for a project
*/
subscribe(projectId, configKey) {
const subscriptionId = configKey ? `${projectId}:${configKey}` : projectId;
const subscription = {
project_id: projectId,
config_key: configKey
};
this.subscriptions.set(subscriptionId, subscription);
if (this.connectionState === 'connected') {
this.sendSubscription(subscription);
}
this.logger.debug('Subscription added', { projectId, configKey });
}
/**
* Unsubscribe from configuration updates
*/
unsubscribe(projectId, configKey) {
const subscriptionId = configKey ? `${projectId}:${configKey}` : projectId;
this.subscriptions.delete(subscriptionId);
if (this.connectionState === 'connected') {
this.sendMessage({
type: 'unsubscribe',
data: configKey ? `config_updates:${projectId}:${configKey}` : `config_updates:${projectId}`
});
}
this.logger.debug('Subscription removed', { projectId, configKey });
}
/**
* Add callback for configuration updates
*/
onConfigUpdate(configKey, callback) {
if (!this.configCallbacks.has(configKey)) {
this.configCallbacks.set(configKey, new Set());
}
this.configCallbacks.get(configKey).add(callback);
// Return unsubscribe function
return () => {
const callbacks = this.configCallbacks.get(configKey);
if (callbacks) {
callbacks.delete(callback);
if (callbacks.size === 0) {
this.configCallbacks.delete(configKey);
}
}
};
}
/**
* Add callback for connection state changes
*/
onConnectionState(callback) {
this.connectionStateCallbacks.add(callback);
// Return unsubscribe function
return () => {
this.connectionStateCallbacks.delete(callback);
};
}
/**
* Add callback for errors
*/
onError(callback) {
this.errorCallbacks.add(callback);
// Return unsubscribe function
return () => {
this.errorCallbacks.delete(callback);
};
}
/**
* Get current connection state
*/
getConnectionState() {
return this.connectionState;
}
/**
* Check if WebSocket is connected
*/
isConnected() {
return this.connectionState === 'connected' && this.ws?.readyState === WebSocket.OPEN;
}
/**
* Handle incoming WebSocket messages
*/
handleMessage(data) {
try {
const message = JSON.parse(data);
this.logger.debug('WebSocket message received', { type: message.type });
switch (message.type) {
case 'config_update':
this.handleConfigUpdate(message);
break;
case 'subscribed':
this.logger.debug('Subscription confirmed', { channel: message.channel });
break;
case 'unsubscribed':
this.logger.debug('Unsubscription confirmed', { channel: message.channel });
break;
case 'pong':
this.lastPong = Date.now();
this.logger.debug('Pong received');
break;
case 'error':
this.logger.error('Server error', { data: message.data });
this.notifyError(new Error(message.data?.message || 'Server error'));
break;
default:
this.logger.warn('Unknown message type', { type: message.type });
}
}
catch (error) {
this.logger.error('Failed to parse WebSocket message', {
error: error instanceof Error ? error.message : String(error),
data
});
}
}
/**
* Handle configuration update messages
*/
handleConfigUpdate(message) {
try {
const event = message.data;
this.logger.info('Configuration update received', {
configKey: event.config_key,
updateType: event.update_type,
version: event.version
});
// Notify specific config callbacks
const callbacks = this.configCallbacks.get(event.config_key);
if (callbacks) {
callbacks.forEach(callback => {
try {
callback(event);
}
catch (error) {
this.logger.error('Config update callback error', {
configKey: event.config_key,
error: error instanceof Error ? error.message : String(error)
});
}
});
}
// Notify wildcard callbacks (empty string key)
const wildcardCallbacks = this.configCallbacks.get('*');
if (wildcardCallbacks) {
wildcardCallbacks.forEach(callback => {
try {
callback(event);
}
catch (error) {
this.logger.error('Wildcard config update callback error', {
configKey: event.config_key,
error: error instanceof Error ? error.message : String(error)
});
}
});
}
}
catch (error) {
this.logger.error('Failed to handle config update', {
error: error instanceof Error ? error.message : String(error)
});
}
}
/**
* Send subscription message
*/
sendSubscription(subscription) {
this.sendMessage({
type: 'subscribe',
data: subscription
});
}
/**
* Send message to WebSocket server
*/
sendMessage(message) {
if (!this.isConnected()) {
this.logger.warn('Cannot send message: WebSocket not connected', { type: message.type });
return;
}
try {
const fullMessage = {
type: message.type || 'unknown',
channel: message.channel,
data: message.data,
timestamp: new Date().toISOString()
};
this.ws.send(JSON.stringify(fullMessage));
this.logger.debug('Message sent', { type: fullMessage.type });
}
catch (error) {
this.logger.error('Failed to send WebSocket message', {
error: error instanceof Error ? error.message : String(error),
type: message.type
});
}
}
/**
* Resubscribe to all active subscriptions after reconnection
*/
resubscribeAll() {
this.subscriptions.forEach(subscription => {
this.sendSubscription(subscription);
});
this.logger.debug('Resubscribed to all subscriptions', {
count: this.subscriptions.size
});
}
/**
* Start heartbeat to keep connection alive
*/
startHeartbeat() {
this.clearHeartbeat();
this.lastPong = Date.now();
this.heartbeatTimer = setInterval(() => {
if (this.isConnected()) {
// Check if we received a pong recently
if (Date.now() - this.lastPong > 60000) { // 60 seconds
this.logger.warn('No pong received, connection may be stale');
this.ws?.close();
return;
}
// Send ping
this.sendMessage({ type: 'ping' });
}
}, 30000); // Every 30 seconds
}
/**
* Clear heartbeat timer
*/
clearHeartbeat() {
if (this.heartbeatTimer) {
clearInterval(this.heartbeatTimer);
this.heartbeatTimer = null;
}
}
/**
* Schedule reconnection attempt
*/
scheduleReconnect() {
if (this.reconnectTimer) {
return;
}
this.reconnectAttempts++;
const delay = Math.min(this.config.reconnectInterval * Math.pow(2, this.reconnectAttempts - 1), 30000);
this.logger.info(`Scheduling reconnection attempt ${this.reconnectAttempts}/${this.config.maxReconnectAttempts}`, {
delay
});
this.reconnectTimer = setTimeout(async () => {
this.clearReconnectTimer();
try {
await this.connect();
}
catch (error) {
this.logger.error('Reconnection attempt failed', {
attempt: this.reconnectAttempts,
error: error instanceof Error ? error.message : String(error)
});
}
}, delay);
}
/**
* Clear reconnection timer
*/
clearReconnectTimer() {
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
}
/**
* Clear connection timer
*/
clearConnectionTimer() {
if (this.connectionTimer) {
clearTimeout(this.connectionTimer);
this.connectionTimer = null;
}
}
/**
* Update connection state and notify callbacks
*/
setConnectionState(state) {
if (this.connectionState !== state) {
this.connectionState = state;
this.logger.debug('Connection state changed', { state });
this.connectionStateCallbacks.forEach(callback => {
try {
callback(state);
}
catch (error) {
this.logger.error('Connection state callback error', {
error: error instanceof Error ? error.message : String(error)
});
}
});
}
}
/**
* Notify error callbacks
*/
notifyError(error) {
this.errorCallbacks.forEach(callback => {
try {
callback(error);
}
catch (callbackError) {
this.logger.error('Error callback failed', {
error: callbackError instanceof Error ? callbackError.message : String(callbackError)
});
}
});
}
/**
* Normalize configuration with defaults
*/
normalizeConfig(config) {
return {
baseUrl: config.baseUrl,
token: config.token,
reconnectInterval: config.reconnectInterval || 5000,
maxReconnectAttempts: config.maxReconnectAttempts || 10,
connectionTimeout: config.connectionTimeout || 10000,
autoReconnect: config.autoReconnect !== false
};
}
}
exports.WebSocketClient = WebSocketClient;
//# sourceMappingURL=websocket-client.js.map