@varia-bly/variably-sdk
Version:
Official JavaScript/TypeScript SDK for Variably feature flags, experimentation, and real-time dynamic configurations
491 lines • 18.8 kB
JavaScript
"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