UNPKG

@varia-bly/variably-sdk

Version:

Official JavaScript/TypeScript SDK for Variably feature flags, experimentation, and real-time dynamic configurations

491 lines 18.8 kB
"use strict"; /** * Dynamic Configuration Client with Real-time Updates */ Object.defineProperty(exports, "__esModule", { value: true }); exports.DynamicConfigClient = void 0; const websocket_client_1 = require("./websocket-client"); const cache_1 = require("./cache"); const logger_1 = require("./logger"); const metrics_1 = require("./metrics"); const errors_1 = require("./errors"); class DynamicConfigClient { constructor(config) { this.wsClient = null; // State management this.configValues = new Map(); this.configCallbacks = new Map(); this.pollingTimer = null; this.isRealTimeMode = false; this.fallbackMode = false; this.config = this.normalizeConfig(config); this.logger = new logger_1.ConsoleLogger(this.config.debug ? 'debug' : 'info'); this.metrics = new metrics_1.MetricsCollector(); this.cache = new cache_1.CacheManager(this.config.cache, this.logger); // Initialize WebSocket client if real-time is enabled if (this.config.enableRealtime && this.config.jwtToken) { this.initializeWebSocket(); } else { this.logger.info('Real-time updates disabled, using polling mode only'); this.startPolling(); } this.logger.info('DynamicConfigClient initialized', { projectId: this.config.projectId, enableRealtime: this.config.enableRealtime, pollingInterval: this.config.pollingInterval }); } /** * Get a dynamic configuration value */ async getConfig(configKey, defaultValue, userContext) { const result = await this.evaluateConfig(configKey, defaultValue, userContext); return result.error ? defaultValue : result.value; } /** * Get a boolean dynamic configuration */ async getConfigBool(configKey, defaultValue, userContext) { const result = await this.evaluateConfig(configKey, defaultValue, userContext); return typeof result.value === 'boolean' ? result.value : defaultValue; } /** * Get a string dynamic configuration */ async getConfigString(configKey, defaultValue, userContext) { const result = await this.evaluateConfig(configKey, defaultValue, userContext); return typeof result.value === 'string' ? result.value : defaultValue; } /** * Get a number dynamic configuration */ async getConfigNumber(configKey, defaultValue, userContext) { const result = await this.evaluateConfig(configKey, defaultValue, userContext); return typeof result.value === 'number' ? result.value : defaultValue; } /** * Get a JSON dynamic configuration */ async getConfigJSON(configKey, defaultValue, userContext) { const result = await this.evaluateConfig(configKey, defaultValue, userContext); return result.error ? defaultValue : result.value; } /** * Evaluate a dynamic configuration with full details */ async evaluateConfig(configKey, defaultValue, userContext) { try { // Validate inputs if (!configKey || typeof configKey !== 'string') { throw new errors_1.ConfigurationError('Config key must be a non-empty string', 'configKey'); } if (!userContext?.userId) { throw new errors_1.ConfigurationError('User context must include userId', 'userContext.userId'); } // Check cache first const cacheKey = this.generateCacheKey(configKey, userContext); const cached = this.cache.get(cacheKey); if (cached) { this.metrics.recordCacheHit(); this.logger.debug('Config evaluation cache hit', { configKey, userId: userContext.userId }); return { ...cached, cacheHit: true, realTimeUpdate: false }; } this.metrics.recordCacheMiss(); // Evaluate via API with ETag support const response = await this.evaluateConfigViaAPI(configKey, userContext); const result = { key: configKey, value: response?.value || defaultValue, reason: response?.reason || 'api_evaluation', ruleId: response?.rule_id, etag: response?.etag, version: response?.version, updatedAt: response?.updated_at ? new Date(response.updated_at) : undefined, cacheHit: false, realTimeUpdate: false, retrievedAt: new Date() }; // Cache the result this.cache.set(cacheKey, result); this.configValues.set(configKey, result); this.logger.debug('Config evaluation successful', { configKey, value: response?.value, version: response?.version, userId: userContext.userId }); return result; } catch (error) { this.logger.error('Config evaluation failed', { configKey, error: error instanceof Error ? error.message : String(error), userId: userContext.userId }); // Return default value with error return { key: configKey, value: defaultValue, reason: 'error_fallback', cacheHit: false, realTimeUpdate: false, retrievedAt: new Date(), error: error instanceof Error ? error : new Error(String(error)) }; } } /** * Subscribe to real-time updates for a specific configuration */ onConfigChange(configKey, callback) { if (!this.configCallbacks.has(configKey)) { this.configCallbacks.set(configKey, new Set()); } this.configCallbacks.get(configKey).add(callback); // Subscribe to WebSocket updates if available if (this.wsClient && this.isRealTimeMode) { this.wsClient.subscribe(this.config.projectId, configKey); } this.logger.debug('Config change subscription added', { configKey }); // Return unsubscribe function return () => { const callbacks = this.configCallbacks.get(configKey); if (callbacks) { callbacks.delete(callback); if (callbacks.size === 0) { this.configCallbacks.delete(configKey); // Unsubscribe from WebSocket if no more callbacks if (this.wsClient) { this.wsClient.unsubscribe(this.config.projectId, configKey); } } } }; } /** * Subscribe to all configuration changes in the project */ onAnyConfigChange(callback) { return this.onConfigChange('*', callback); } /** * Refresh all cached configurations */ async refreshConfigs(userContext) { const configKeys = Array.from(this.configValues.keys()); if (configKeys.length === 0) { this.logger.debug('No configs to refresh'); return; } this.logger.info('Refreshing cached configurations', { count: configKeys.length }); // Clear cache and refresh each config this.cache.clear(); for (const configKey of configKeys) { try { const cached = this.configValues.get(configKey); if (cached) { await this.evaluateConfig(configKey, cached.value, userContext); } } catch (error) { this.logger.error('Failed to refresh config', { configKey, error: error instanceof Error ? error.message : String(error) }); } } } /** * Get current connection status */ getConnectionStatus() { if (this.wsClient && this.isRealTimeMode) { return { mode: 'realtime', connected: this.wsClient.isConnected(), fallbackActive: this.fallbackMode }; } return { mode: 'polling', connected: true, fallbackActive: false }; } /** * Disconnect and cleanup */ disconnect() { this.logger.info('Disconnecting DynamicConfigClient'); if (this.wsClient) { this.wsClient.disconnect(); } if (this.pollingTimer) { clearInterval(this.pollingTimer); this.pollingTimer = null; } this.configCallbacks.clear(); this.configValues.clear(); this.cache.clear(); } /** * Initialize WebSocket client and set up real-time updates */ initializeWebSocket() { if (!this.config.jwtToken) { this.logger.warn('JWT token not provided, falling back to polling mode'); this.startPolling(); return; } const wsConfig = { baseUrl: this.config.baseUrl, token: this.config.jwtToken, ...this.config.websocket }; this.wsClient = new websocket_client_1.WebSocketClient(wsConfig, this.logger); // Set up event handlers this.wsClient.onConnectionState((state) => { this.logger.debug('WebSocket connection state changed', { state }); if (state === 'connected') { this.isRealTimeMode = true; this.fallbackMode = false; this.stopPolling(); // Subscribe to project-wide updates this.wsClient.subscribe(this.config.projectId); // Subscribe to specific configs that have callbacks this.configCallbacks.forEach((_, configKey) => { if (configKey !== '*') { this.wsClient.subscribe(this.config.projectId, configKey); } }); } else if (state === 'disconnected' || state === 'error') { this.isRealTimeMode = false; if (!this.fallbackMode) { this.logger.info('WebSocket disconnected, falling back to polling mode'); this.fallbackMode = true; this.startPolling(); } } }); this.wsClient.onConfigUpdate('*', (event) => { this.handleRealtimeConfigUpdate(event); }); this.wsClient.onError((error) => { this.logger.error('WebSocket error', { error: error.message }); }); // Connect to WebSocket this.wsClient.connect().catch((error) => { this.logger.error('Failed to connect to WebSocket', { error: error.message }); this.fallbackMode = true; this.startPolling(); }); } /** * Handle real-time configuration updates from WebSocket */ handleRealtimeConfigUpdate(event) { this.logger.info('Real-time config update received', { configKey: event.config_key, updateType: event.update_type, version: event.version }); // Update cached value const result = { key: event.config_key, value: event.new_value, reason: 'realtime_update', etag: event.etag, version: event.version, updatedAt: new Date(event.timestamp), cacheHit: false, realTimeUpdate: true, retrievedAt: new Date() }; // Update cache and local state this.configValues.set(event.config_key, result); // Clear related cache entries this.cache.clearByPattern(`config:${event.config_key}:*`); // Notify callbacks this.notifyConfigCallbacks(event.config_key, result); } /** * Notify configuration change callbacks */ notifyConfigCallbacks(configKey, result) { // Notify specific config callbacks const callbacks = this.configCallbacks.get(configKey); if (callbacks) { callbacks.forEach(callback => { try { callback(result); } catch (error) { this.logger.error('Config change callback error', { configKey, error: error instanceof Error ? error.message : String(error) }); } }); } // Notify wildcard callbacks const wildcardCallbacks = this.configCallbacks.get('*'); if (wildcardCallbacks) { wildcardCallbacks.forEach(callback => { try { callback(result); } catch (error) { this.logger.error('Wildcard config change callback error', { configKey, error: error instanceof Error ? error.message : String(error) }); } }); } } /** * Start polling mode for fallback */ startPolling() { if (this.pollingTimer) { return; } this.logger.info('Starting polling mode', { interval: this.config.pollingInterval }); this.pollingTimer = setInterval(async () => { await this.pollConfigurations(); }, this.config.pollingInterval); } /** * Stop polling mode */ stopPolling() { if (this.pollingTimer) { clearInterval(this.pollingTimer); this.pollingTimer = null; this.logger.debug('Polling mode stopped'); } } /** * Poll for configuration changes (fallback mode) */ async pollConfigurations() { if (this.configValues.size === 0) { return; } this.logger.debug('Polling configurations for changes', { count: this.configValues.size }); for (const [configKey, cachedResult] of this.configValues) { try { // Use a generic user context for polling - in real usage, // apps should maintain their current user context const userContext = { userId: 'polling-user' }; const response = await this.evaluateConfigViaAPI(configKey, userContext, cachedResult.etag); if (response) { // Configuration has changed const result = { key: configKey, value: response.value, reason: 'polling_update', ruleId: response.rule_id, etag: response.etag, version: response.version, updatedAt: response.updated_at ? new Date(response.updated_at) : undefined, cacheHit: false, realTimeUpdate: false, retrievedAt: new Date() }; this.configValues.set(configKey, result); this.notifyConfigCallbacks(configKey, result); } } catch (error) { this.logger.error('Polling failed for config', { configKey, error: error instanceof Error ? error.message : String(error) }); } } } /** * Evaluate configuration via HTTP API with ETag support */ async evaluateConfigViaAPI(configKey, userContext, ifNoneMatch) { const request = { config_key: configKey, context: userContext }; // Add If-None-Match header if ETag is provided const headers = {}; if (ifNoneMatch) { headers['If-None-Match'] = ifNoneMatch; } try { const response = await fetch(`${this.config.baseUrl}/api/v1/sdk/dynamic-configs/evaluate`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${this.config.apiKey}`, ...headers }, body: JSON.stringify(request) }); if (response.status === 304) { // Not Modified - return null to indicate no change return null; } if (!response.ok) { throw new errors_1.NetworkError(`HTTP ${response.status}: ${response.statusText}`, response.status, response.url); } return await response.json(); } catch (error) { if (error instanceof errors_1.NetworkError) { throw error; } throw new errors_1.NetworkError(`Failed to evaluate config: ${error instanceof Error ? error.message : String(error)}`, 0, `${this.config.baseUrl}/api/v1/sdk/dynamic-configs/evaluate`, error instanceof Error ? error : undefined); } } /** * Generate cache key for configuration */ generateCacheKey(configKey, userContext) { const contextHash = this.hashUserContext(userContext); return `config:${configKey}:${contextHash}`; } /** * Generate a simple hash of user context for caching */ hashUserContext(userContext) { const key = `${userContext.userId}:${JSON.stringify(userContext.attributes || {})}`; let hash = 0; for (let i = 0; i < key.length; i++) { const char = key.charCodeAt(i); hash = ((hash << 5) - hash) + char; hash = hash & hash; } return hash.toString(36); } /** * Normalize configuration with defaults */ normalizeConfig(config) { return { apiKey: config.apiKey, jwtToken: config.jwtToken || '', baseUrl: config.baseUrl || 'https://graphql.variably.tech', projectId: config.projectId, enableRealtime: config.enableRealtime !== false, pollingInterval: config.pollingInterval || 30000, cache: { ttl: config.cache?.ttl || 300000, // 5 minutes maxSize: config.cache?.maxSize || 1000, enabled: config.cache?.enabled !== false }, websocket: config.websocket || {}, debug: config.debug || false }; } } exports.DynamicConfigClient = DynamicConfigClient; //# sourceMappingURL=dynamic-config-client.js.map