UNPKG

@varia-bly/variably-sdk

Version:

Official JavaScript/TypeScript SDK for Variably feature flags, experimentation, LLM experiments with React hooks, and real-time dynamic configurations

367 lines 14.1 kB
/** * GraphQL WebSocket subscription client for real-time Feature Gate updates * Uses graphql-ws protocol for GraphQL subscriptions */ import { createClient } from 'graphql-ws'; // Use native WebSocket in browser, ws package in Node.js const WebSocketImpl = typeof window !== 'undefined' && window.WebSocket ? window.WebSocket : require('ws'); export class GraphQLSubscriptionClient { constructor(config, logger) { this.client = null; this.connectionState = 'disconnected'; // Callbacks this.gateUpdateCallbacks = new Map(); this.connectionStateCallbacks = new Set(); // Subscription management this.activeSubscriptions = new Map(); this.reconnectAttempts = 0; this.reconnectTimer = null; this.config = { url: config.url, apiKey: config.apiKey, projectId: config.projectId, autoReconnect: config.autoReconnect !== false, reconnectInterval: config.reconnectInterval || 5000, maxReconnectAttempts: config.maxReconnectAttempts || 10 }; this.logger = logger; } /** * Connect to GraphQL WebSocket server */ async connect() { if (this.connectionState === 'connected' || this.connectionState === 'connecting') { return; } return new Promise((resolve, reject) => { try { this.setConnectionState('connecting'); // Convert HTTP URL to WebSocket URL and append /graphql path const baseWsUrl = this.config.url .replace(/^http:/, 'ws:') .replace(/^https:/, 'wss:'); // Append /graphql if not already present const wsUrl = baseWsUrl.endsWith('/graphql') ? baseWsUrl : `${baseWsUrl}/graphql`; this.logger.debug('Connecting to GraphQL WebSocket', { url: wsUrl }); const clientOptions = { url: wsUrl, webSocketImpl: WebSocketImpl, connectionParams: { apiKey: this.config.apiKey }, retryAttempts: 0, // We handle reconnection ourselves lazy: false, keepAlive: 30000, // 30 seconds on: { opened: (socket) => { this.setConnectionState('connected'); this.reconnectAttempts = 0; this.logger.info('GraphQL WebSocket connected successfully'); // Resubscribe to active subscriptions this.resubscribeAll(); resolve(); }, closed: (event) => { this.setConnectionState('disconnected'); this.logger.warn('GraphQL WebSocket connection closed', { code: event.code, reason: event.reason }); // Attempt reconnection if enabled if (this.config.autoReconnect && this.reconnectAttempts < this.config.maxReconnectAttempts) { this.scheduleReconnect(); } }, error: (error) => { this.setConnectionState('error'); this.logger.error('GraphQL WebSocket error', { error: error instanceof Error ? error.message : String(error) }); if (this.connectionState === 'connecting') { reject(error); } }, ping: (received) => { this.logger.debug('Received ping from server'); }, pong: (received) => { this.logger.debug('Received pong from server'); } } }; this.client = createClient(clientOptions); } catch (error) { this.setConnectionState('error'); const wsError = error instanceof Error ? error : new Error('Unknown WebSocket error'); this.logger.error('Failed to create GraphQL WebSocket connection', { error: wsError.message }); reject(wsError); } }); } /** * Disconnect from GraphQL WebSocket server */ disconnect() { this.config.autoReconnect = false; this.clearReconnectTimer(); // Unsubscribe from all active subscriptions this.activeSubscriptions.forEach((unsubscribe) => { unsubscribe(); }); this.activeSubscriptions.clear(); if (this.client) { this.client.dispose(); this.client = null; } this.setConnectionState('disconnected'); this.logger.info('GraphQL WebSocket disconnected'); } /** * Subscribe to Feature Gate updates for a specific project */ subscribeToFeatureGates(projectId, gateKey) { const subscriptionId = gateKey ? `${projectId}:${gateKey}` : projectId; // If already subscribed, return existing unsubscribe function if (this.activeSubscriptions.has(subscriptionId)) { this.logger.debug('Already subscribed to Feature Gate updates', { projectId, gateKey }); return this.activeSubscriptions.get(subscriptionId); } if (!this.client) { this.logger.warn('Cannot subscribe: Client not connected'); return () => { }; } this.logger.info('Subscribing to Feature Gate updates', { projectId, gateKey }); const subscription = { query: ` subscription FeatureGateUpdated($projectId: ID!, $gateKey: String) { featureGateUpdated(projectId: $projectId, gateKey: $gateKey) { id gateKey projectId isEnabled description trafficAllocation updatedAt } } `, variables: { projectId, gateKey: gateKey || null } }; const unsubscribe = this.client.subscribe(subscription, { next: (data) => { this.logger.debug('Received Feature Gate update', { data }); if (data.data && data.data.featureGateUpdated) { const gateUpdate = data.data.featureGateUpdated; this.handleGateUpdate(gateUpdate); } }, error: (errors) => { // graphql-ws returns an array of GraphQL errors let errorMessage = 'Unknown error'; if (Array.isArray(errors) && errors.length > 0) { errorMessage = errors.map((e) => e.message || JSON.stringify(e)).join(', '); } else if (errors instanceof Error) { errorMessage = errors.message; } else if (typeof errors === 'string') { errorMessage = errors; } else { try { errorMessage = JSON.stringify(errors); } catch { errorMessage = String(errors); } } this.logger.error('Feature Gate subscription error', { error: errorMessage, projectId, gateKey }); }, complete: () => { this.logger.debug('Feature Gate subscription completed', { projectId, gateKey }); this.activeSubscriptions.delete(subscriptionId); } }); this.activeSubscriptions.set(subscriptionId, unsubscribe); // Return unsubscribe function return () => { this.logger.debug('Unsubscribing from Feature Gate updates', { projectId, gateKey }); unsubscribe(); this.activeSubscriptions.delete(subscriptionId); }; } /** * Add callback for Feature Gate updates */ onFeatureGateUpdate(gateKey, callback) { if (!this.gateUpdateCallbacks.has(gateKey)) { this.gateUpdateCallbacks.set(gateKey, new Set()); } this.gateUpdateCallbacks.get(gateKey).add(callback); // Return unsubscribe function return () => { const callbacks = this.gateUpdateCallbacks.get(gateKey); if (callbacks) { callbacks.delete(callback); if (callbacks.size === 0) { this.gateUpdateCallbacks.delete(gateKey); } } }; } /** * Add callback for all Feature Gate updates (wildcard) */ onAnyFeatureGateUpdate(callback) { return this.onFeatureGateUpdate('*', callback); } /** * Add callback for connection state changes */ onConnectionState(callback) { this.connectionStateCallbacks.add(callback); // Return unsubscribe function return () => { this.connectionStateCallbacks.delete(callback); }; } /** * Get current connection state */ getConnectionState() { return this.connectionState; } /** * Check if client is connected */ isConnected() { return this.connectionState === 'connected' && this.client !== null; } /** * Handle Feature Gate update from subscription */ handleGateUpdate(gateUpdate) { this.logger.info('Processing Feature Gate update', { gateKey: gateUpdate.gateKey, enabled: gateUpdate.isEnabled, trafficAllocation: gateUpdate.trafficAllocation, projectId: gateUpdate.projectId }); // Notify specific gate callbacks const callbacks = this.gateUpdateCallbacks.get(gateUpdate.gateKey); if (callbacks) { callbacks.forEach(callback => { try { callback(gateUpdate); } catch (error) { this.logger.error('Feature Gate update callback error', { gateKey: gateUpdate.gateKey, error: error instanceof Error ? error.message : String(error) }); } }); } // Notify wildcard callbacks const wildcardCallbacks = this.gateUpdateCallbacks.get('*'); if (wildcardCallbacks) { wildcardCallbacks.forEach(callback => { try { callback(gateUpdate); } catch (error) { this.logger.error('Wildcard Feature Gate update callback error', { gateKey: gateUpdate.gateKey, error: error instanceof Error ? error.message : String(error) }); } }); } } /** * Resubscribe to all active subscriptions after reconnection */ resubscribeAll() { const subscriptionIds = Array.from(this.activeSubscriptions.keys()); if (subscriptionIds.length === 0) { return; } this.logger.info('Resubscribing to all active subscriptions', { count: subscriptionIds.length }); // Clear existing subscriptions this.activeSubscriptions.forEach((unsubscribe) => { unsubscribe(); }); this.activeSubscriptions.clear(); // Resubscribe to each subscription subscriptionIds.forEach((subscriptionId) => { const [projectId, gateKey] = subscriptionId.split(':'); this.subscribeToFeatureGates(projectId, gateKey); }); } /** * 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; } } /** * 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) }); } }); } } } //# sourceMappingURL=graphql-subscription-client.js.map