UNPKG

react-azure-config

Version:

🚀 The Ultimate Multi-App Configuration Library! CRITICAL BUG FIXES: Prefixed environment keys no longer sent to Azure. Complete architectural redesign with bulletproof fallback system. Enterprise-grade Azure integration and monorepo support.

1,344 lines (1,335 loc) • 173 kB
import { AppConfigurationClient } from '@azure/app-configuration'; import { ClientSecretCredential, DefaultAzureCredential } from '@azure/identity'; import { SecretClient } from '@azure/keyvault-secrets'; import express from 'express'; import cors from 'cors'; import { existsSync, readFileSync } from 'fs'; import { join } from 'path'; const getEnvVar = (key, defaultValue = '') => { try { return process.env[key] || defaultValue; } catch { return defaultValue; } }; const environment = { isServer: typeof window === 'undefined', isClient: typeof window !== 'undefined', isBrowser: typeof window !== 'undefined' && typeof window.document !== 'undefined', isDevelopment: getEnvVar('NODE_ENV') === 'development', isProduction: getEnvVar('NODE_ENV') === 'production', isTest: getEnvVar('NODE_ENV') === 'test' }; class Logger { constructor(config = {}) { const defaultLevel = this.getDefaultLogLevel(); const defaultPrefix = getEnvVar('LOG_PREFIX', '[ReactAzureConfig]'); this.config = { level: getEnvVar('LOG_LEVEL') || defaultLevel, prefix: defaultPrefix, enableTimestamp: environment.isProduction, enableColors: !environment.isProduction, ...config }; } getDefaultLogLevel() { if (environment.isProduction) return 'warn'; if (environment.isTest) return 'silent'; return 'debug'; } formatMessage(level, message) { let formatted = `${this.config.prefix} ${message}`; if (this.config.enableTimestamp) { const timestamp = new Date().toISOString(); formatted = `[${timestamp}] ${formatted}`; } if (this.config.enableColors && typeof window === 'undefined') { const colors = { debug: '\x1b[36m', info: '\x1b[32m', warn: '\x1b[33m', error: '\x1b[31m', silent: '' }; const reset = '\x1b[0m'; formatted = `${colors[level]}${formatted}${reset}`; } return formatted; } shouldLog(level) { const levels = ['debug', 'info', 'warn', 'error', 'silent']; const currentLevelIndex = levels.indexOf(this.config.level); const targetLevelIndex = levels.indexOf(level); return targetLevelIndex >= currentLevelIndex && this.config.level !== 'silent'; } debug(message, ...args) { if (this.shouldLog('debug')) { console.debug(this.formatMessage('debug', message), ...args); } } info(message, ...args) { if (this.shouldLog('info')) { console.info(this.formatMessage('info', message), ...args); } } warn(message, ...args) { if (this.shouldLog('warn')) { console.warn(this.formatMessage('warn', message), ...args); } } error(message, ...args) { if (this.shouldLog('error')) { console.error(this.formatMessage('error', message), ...args); } } setLevel(level) { this.config.level = level; } } const logger = new Logger(); const safeGetEnv = (key, defaultValue = '') => { try { return process.env[key] || defaultValue; } catch { return defaultValue; } }; const getEnvNumber = (key, defaultValue) => { const value = safeGetEnv(key); return value ? parseInt(value, 10) : defaultValue; }; const getEnvString = (key, defaultValue) => { return safeGetEnv(key, defaultValue); }; const getEnvArray = (key, defaultValue) => { const value = safeGetEnv(key); return value ? value.split(',').map(s => s.trim()) : defaultValue; }; const DEFAULT_CONSTANTS = { CONFIG_SERVER_PORT: getEnvNumber('CONFIG_SERVER_PORT', 3001), APP_SERVER_PORT: getEnvNumber('APP_SERVER_PORT', 3000), DEFAULT_TIMEOUT: getEnvNumber('DEFAULT_TIMEOUT', 30000), DEFAULT_CACHE_TTL: getEnvNumber('CACHE_TTL', 60 * 60 * 1000), LOCAL_CONFIG_TTL: getEnvNumber('LOCAL_CONFIG_TTL', 30000), DEFAULT_CACHE_SIZE: getEnvNumber('CACHE_MAX_SIZE', 1000), MEMORY_CACHE_SIZE: getEnvNumber('MEMORY_CACHE_SIZE', 100), MEMORY_CACHE_TTL: getEnvNumber('MEMORY_CACHE_TTL', 5 * 60 * 1000), CACHE_CLEANUP_INTERVAL: getEnvNumber('CACHE_CLEANUP_INTERVAL', 60 * 1000), MAX_RETRIES: getEnvNumber('MAX_RETRIES', 3), RETRY_DELAY_MS: getEnvNumber('RETRY_DELAY_MS', 1000), DEFAULT_BASE_URL: getEnvString('DEFAULT_BASE_URL', 'http://localhost:3000'), CACHE_KEY_PREFIX: getEnvString('CACHE_KEY_PREFIX', 'react-azure-config:'), ENV_VAR_PREFIX: getEnvString('ENV_VAR_PREFIX', 'REACT_APP_'), DEFAULT_APPLICATION: getEnvString('DEFAULT_APPLICATION', 'react-app'), DEFAULT_ENVIRONMENT: getEnvString('NODE_ENV', 'local'), KV_SECRET_CACHE_TTL: getEnvNumber('AZURE_KEYVAULT_CACHE_TTL', 60 * 60 * 1000), KV_REFRESH_INTERVAL: getEnvNumber('AZURE_KEYVAULT_REFRESH_INTERVAL', 30 * 60 * 1000), DEFAULT_CORS_ORIGINS: getEnvArray('CONFIG_SERVER_CORS_ORIGINS', ['http://localhost:3000', 'http://localhost:3001']) }; const REFRESH_STRATEGIES = { LOAD_ONCE: 'load-once', PERIODIC: 'periodic', ON_DEMAND: 'on-demand' }; const AUTH_TYPES = { SERVICE_PRINCIPAL: 'servicePrincipal', MANAGED_IDENTITY: 'managedIdentity', AZURE_CLI: 'azureCli' }; const CONFIG_SOURCES = { AZURE: 'azure', ENVIRONMENT: 'environment', LOCAL: 'local', DEFAULTS: 'defaults' }; const setNestedProperty = (obj, path, value) => { const keys = path.split('.'); let current = obj; for (let i = 0; i < keys.length - 1; i++) { const key = keys[i]; if (!(key in current)) { current[key] = {}; } current = current[key]; } current[keys[keys.length - 1]] = value; }; const getNestedProperty = (obj, path) => { const keys = path.split('.'); let current = obj; for (const k of keys) { if (current && typeof current === 'object' && current !== null && k in current) { current = current[k]; } else { return undefined; } } return current; }; const transformEnvKeyToConfigPath = (envKey) => { return envKey .replace(DEFAULT_CONSTANTS.ENV_VAR_PREFIX, '') .toLowerCase() .split('_') .reduce((acc, part, index) => { if (index === 0) return part; if (part === 'url' || part === 'uri') { return acc + '.url'; } if (part === 'id') { return acc + '.id'; } if (part === 'key' || part === 'secret') { return acc + '.' + part; } return acc + '.' + part; }, ''); }; const getDefaultConfiguration = (environment) => { return { api: { baseUrl: DEFAULT_CONSTANTS.DEFAULT_BASE_URL, timeout: DEFAULT_CONSTANTS.DEFAULT_TIMEOUT }, auth: { domain: 'localhost' }, features: {}, environment: environment || DEFAULT_CONSTANTS.DEFAULT_ENVIRONMENT }; }; const getDefaultBrowserConfiguration = () => { return { api: { baseUrl: typeof window !== 'undefined' ? window.location.origin : DEFAULT_CONSTANTS.DEFAULT_BASE_URL, timeout: DEFAULT_CONSTANTS.DEFAULT_TIMEOUT }, environment: 'browser', features: {} }; }; const createCacheKey = (key) => { return `${DEFAULT_CONSTANTS.CACHE_KEY_PREFIX}${key}`; }; const isKeyVaultReference = (value) => { if (typeof value !== 'string') return false; try { const parsed = JSON.parse(value); return !!(parsed.uri && parsed.uri.includes('vault.azure.net')); } catch { return false; } }; const delay = (ms) => { return new Promise(resolve => setTimeout(resolve, ms)); }; const parseValue = (value) => { if (typeof value !== 'string') { return value; } if (value.toLowerCase() === 'true') return true; if (value.toLowerCase() === 'false') return false; if (/^\d+$/.test(value)) { return parseInt(value, 10); } if (/^\d*\.\d+$/.test(value)) { return parseFloat(value); } if ((value.startsWith('[') && value.endsWith(']')) || (value.startsWith('{') && value.endsWith('}'))) { try { return JSON.parse(value); } catch { } } return value; }; class ConfigurationCache { constructor(config = {}) { this.memoryCache = new Map(); this.config = { ttl: DEFAULT_CONSTANTS.MEMORY_CACHE_TTL, maxSize: DEFAULT_CONSTANTS.MEMORY_CACHE_SIZE, storage: ['memory', 'localStorage'], ...config }; } get(key) { const now = Date.now(); const memoryValue = this.memoryCache.get(key); if (memoryValue && memoryValue.expires > now) { return memoryValue.value; } if (memoryValue) { this.memoryCache.delete(key); } if (this.config.storage.includes('localStorage') && this.isLocalStorageAvailable()) { try { const stored = localStorage.getItem(createCacheKey(key)); if (stored) { const parsed = JSON.parse(stored); if (parsed.expires > now) { this.memoryCache.set(key, parsed); return parsed.value; } else { localStorage.removeItem(createCacheKey(key)); } } } catch (error) { logger.debug('Failed to read from localStorage:', error); } } return null; } set(key, value, source = 'unknown') { const now = Date.now(); const cachedValue = { value, timestamp: now, expires: now + this.config.ttl, source }; this.memoryCache.set(key, cachedValue); this.evictOldestEntriesIfNeeded(); if (this.config.storage.includes('localStorage') && this.isLocalStorageAvailable()) { try { localStorage.setItem(createCacheKey(key), JSON.stringify(cachedValue)); } catch (error) { logger.debug('Failed to write to localStorage:', error); if (error instanceof DOMException && error.name === 'QuotaExceededError') { this.clearLocalStorageCache(); } } } } clear() { this.memoryCache.clear(); if (this.config.storage.includes('localStorage') && this.isLocalStorageAvailable()) { this.clearLocalStorageCache(); } } clearLocalStorageCache() { try { const keys = Object.keys(localStorage); keys.forEach(key => { if (key.startsWith(DEFAULT_CONSTANTS.CACHE_KEY_PREFIX)) { localStorage.removeItem(key); } }); } catch (error) { logger.debug('Failed to clear localStorage cache:', error); } } cleanExpiredEntries() { const now = Date.now(); const expiredKeys = []; for (const [key, value] of this.memoryCache.entries()) { if (value.expires <= now) { expiredKeys.push(key); } } if (expiredKeys.length > 0) { expiredKeys.forEach(key => { this.memoryCache.delete(key); }); } if (this.config.storage.includes('localStorage') && this.isLocalStorageAvailable()) { this.cleanExpiredLocalStorageEntries(now); } } cleanExpiredLocalStorageEntries(now) { try { const keys = Object.keys(localStorage); keys.forEach(key => { if (key.startsWith(DEFAULT_CONSTANTS.CACHE_KEY_PREFIX)) { try { const stored = localStorage.getItem(key); if (stored) { const parsed = JSON.parse(stored); if (parsed.expires <= now) { localStorage.removeItem(key); } } } catch (error) { localStorage.removeItem(key); } } }); } catch (error) { logger.debug('Failed to clean localStorage expired entries:', error); } } evictOldestEntriesIfNeeded() { this.cleanExpiredEntries(); if (this.memoryCache.size > this.config.maxSize) { const entriesToRemove = this.memoryCache.size - this.config.maxSize; const keysIterator = this.memoryCache.keys(); for (let i = 0; i < entriesToRemove; i++) { const oldestKey = keysIterator.next().value; if (oldestKey) { this.memoryCache.delete(oldestKey); } } } } isLocalStorageAvailable() { try { if (typeof window === 'undefined' || !window.localStorage) { return false; } const testKey = 'react-azure-config:test'; localStorage.setItem(testKey, 'test'); localStorage.removeItem(testKey); return true; } catch (error) { return false; } } getStats() { const now = Date.now(); const memoryStats = { size: this.memoryCache.size, maxSize: this.config.maxSize, ttl: this.config.ttl, entries: Array.from(this.memoryCache.entries()).map(([key, value]) => ({ key, source: value.source, expires: value.expires, isExpired: value.expires <= now })) }; let localStorageStats = null; if (this.config.storage.includes('localStorage') && this.isLocalStorageAvailable()) { try { const keys = Object.keys(localStorage); const configKeys = keys.filter(key => key.startsWith(DEFAULT_CONSTANTS.CACHE_KEY_PREFIX)); localStorageStats = { totalKeys: configKeys.length, storageUsed: JSON.stringify(localStorage).length }; } catch (error) { localStorageStats = { error: 'Failed to read localStorage stats' }; } } return { memory: memoryStats, localStorage: localStorageStats, config: this.config }; } getAllKeys() { const memoryKeys = Array.from(this.memoryCache.keys()); if (this.config.storage.includes('localStorage') && this.isLocalStorageAvailable()) { try { const localStorageKeys = Object.keys(localStorage) .filter(key => key.startsWith(DEFAULT_CONSTANTS.CACHE_KEY_PREFIX)) .map(key => key.replace(DEFAULT_CONSTANTS.CACHE_KEY_PREFIX, '')); return Array.from(new Set([...memoryKeys, ...localStorageKeys])); } catch (error) { return memoryKeys; } } return memoryKeys; } delete(key) { const memoryDeleted = this.memoryCache.delete(key); if (this.config.storage.includes('localStorage') && this.isLocalStorageAvailable()) { try { localStorage.removeItem(createCacheKey(key)); return true; } catch (error) { return memoryDeleted; } } return memoryDeleted; } getConfig() { return { ...this.config }; } } class LocalConfigurationProvider { constructor() { this.config = {}; this.lastLoaded = 0; this.cacheTtl = DEFAULT_CONSTANTS.LOCAL_CONFIG_TTL; this.loadLocalConfig(); } loadLocalConfig() { const now = Date.now(); if (now - this.lastLoaded < this.cacheTtl && Object.keys(this.config).length > 0) { return; } this.config = this.detectAndLoadConfiguration(); this.lastLoaded = now; } detectAndLoadConfiguration() { if (this.isNodeEnvironment()) { try { const config = this.transformEnvToConfig(process.env); logger.debug('Loaded configuration from process.env'); return config; } catch (error) { logger.warn('Failed to access process.env during SSR, using defaults'); return getDefaultConfiguration(); } } if (this.isBrowserEnvironment()) { const windowAny = window; if (windowAny.ENV) { logger.debug('Loaded configuration from window.ENV'); return windowAny.ENV; } if (windowAny.process?.env) { const config = this.transformEnvToConfig(windowAny.process.env); logger.debug('Loaded configuration from window.process.env'); return config; } logger.debug('No local configuration source found in browser'); return getDefaultBrowserConfiguration(); } logger.debug('No local configuration source found'); return getDefaultConfiguration(); } isNodeEnvironment() { try { return typeof process !== 'undefined' && !!process.env; } catch { return false; } } isBrowserEnvironment() { return typeof window !== 'undefined'; } transformEnvToConfig(env) { const config = {}; Object.entries(env).forEach(([key, value]) => { if (key.startsWith(DEFAULT_CONSTANTS.ENV_VAR_PREFIX)) { const configKey = transformEnvKeyToConfigPath(key); setNestedProperty(config, configKey, parseValue(value)); } else if (['NODE_ENV', 'ENVIRONMENT', 'ENV'].includes(key)) { config.environment = value; } else if (key.startsWith('AZURE_')) { const azureKey = key.toLowerCase().replace('azure_', ''); if (!config.azure) config.azure = {}; config.azure[azureKey] = value; } }); if (!config.environment) { config.environment = env.NODE_ENV || 'local'; } return config; } getConfiguration() { this.loadLocalConfig(); return { ...this.config }; } getValue(key) { this.loadLocalConfig(); const keys = key.split('.'); let current = this.config; for (const k of keys) { if (current && typeof current === 'object' && current !== null && k in current) { current = current[k]; } else { return undefined; } } return current; } } var ErrorType; (function (ErrorType) { ErrorType["CONFIGURATION_ERROR"] = "CONFIGURATION_ERROR"; ErrorType["AZURE_CLIENT_ERROR"] = "AZURE_CLIENT_ERROR"; ErrorType["NETWORK_ERROR"] = "NETWORK_ERROR"; ErrorType["CACHE_ERROR"] = "CACHE_ERROR"; ErrorType["KEYVAULT_ERROR"] = "KEYVAULT_ERROR"; ErrorType["VALIDATION_ERROR"] = "VALIDATION_ERROR"; ErrorType["SERVER_ERROR"] = "SERVER_ERROR"; })(ErrorType || (ErrorType = {})); class ConfigurationError extends Error { constructor(type, message, context, originalError) { super(message); this.name = 'ConfigurationError'; this.type = type; this.context = context; this.originalError = originalError; if (Error.captureStackTrace) { Error.captureStackTrace(this, ConfigurationError); } } toJSON() { return { name: this.name, type: this.type, message: this.message, context: this.context, stack: this.stack, originalError: this.originalError?.message }; } } const handleError = (error, type, context, logLevel = 'error') => { const originalError = error instanceof Error ? error : undefined; const message = error instanceof Error ? error.message : String(error); const configError = new ConfigurationError(type, message, context, originalError); logger[logLevel](`${type}: ${message}`, { context, originalError: originalError?.stack }); return configError; }; class AzureConfigurationClient { constructor(options) { this.client = null; this.localProvider = null; this.retryCount = 0; this.maxRetries = DEFAULT_CONSTANTS.MAX_RETRIES; this.keyVaultClients = new Map(); this.credential = null; this.configurationLoaded = false; this.options = { application: DEFAULT_CONSTANTS.DEFAULT_APPLICATION, enableLocalFallback: true, sources: [CONFIG_SOURCES.AZURE, CONFIG_SOURCES.ENVIRONMENT, CONFIG_SOURCES.LOCAL, CONFIG_SOURCES.DEFAULTS], precedence: 'first-wins', logLevel: 'warn', ...options }; if (options.logLevel) { logger.setLevel(options.logLevel); } this.cache = new ConfigurationCache({ ttl: DEFAULT_CONSTANTS.DEFAULT_CACHE_TTL, maxSize: DEFAULT_CONSTANTS.DEFAULT_CACHE_SIZE, storage: ['memory', 'localStorage'], refreshStrategy: REFRESH_STRATEGIES.ON_DEMAND, ...options.cache }); this.keyVaultConfig = { enableKeyVaultReferences: true, secretCacheTtl: DEFAULT_CONSTANTS.KV_SECRET_CACHE_TTL, maxRetries: DEFAULT_CONSTANTS.MAX_RETRIES, retryDelayMs: DEFAULT_CONSTANTS.RETRY_DELAY_MS, refreshStrategy: REFRESH_STRATEGIES.ON_DEMAND, ...options.keyVault }; if (this.options.environment === 'local') { this.localProvider = new LocalConfigurationProvider(); } else if (this.options.endpoint) { this.initializeAzureClient(); } this.setupPeriodicRefresh(); } initializeAzureClient() { try { this.credential = this.createCredential(); this.client = new AppConfigurationClient(this.options.endpoint, this.credential); logger.debug('Azure App Configuration client initialized'); } catch (error) { const configError = handleError(error, ErrorType.AZURE_CLIENT_ERROR, { endpoint: this.options.endpoint, environment: this.options.environment }); if (this.options.enableLocalFallback) { logger.info('Falling back to local configuration'); this.localProvider = new LocalConfigurationProvider(); } else { throw configError; } } } createCredential() { const auth = this.options.authentication; if (auth?.type === AUTH_TYPES.SERVICE_PRINCIPAL) { if (!auth.tenantId || !auth.clientId || !auth.clientSecret) { throw handleError('Service Principal authentication requires tenantId, clientId, and clientSecret', ErrorType.VALIDATION_ERROR, { authType: auth.type }); } logger.debug(`Using Service Principal authentication for tenant: ${auth.tenantId}`); return new ClientSecretCredential(auth.tenantId, auth.clientId, auth.clientSecret); } if (auth?.type === AUTH_TYPES.MANAGED_IDENTITY) { logger.debug('Using Managed Identity authentication'); const options = auth.clientId ? { managedIdentityClientId: auth.clientId } : undefined; return new DefaultAzureCredential(options); } if (auth?.type === AUTH_TYPES.AZURE_CLI) { logger.debug('Using Azure CLI authentication'); return new DefaultAzureCredential(); } logger.debug('Using DefaultAzureCredential'); return new DefaultAzureCredential(); } async getConfiguration() { const cacheKey = `config:${this.options.environment}:${this.options.application}`; const refreshStrategy = this.cache.getConfig().refreshStrategy || 'on-demand'; if (refreshStrategy === 'load-once' && this.configurationLoaded) { const cached = this.cache.get(cacheKey); if (cached) { logger.debug('Using cached configuration (load-once strategy)'); return cached; } } const cached = this.cache.get(cacheKey); if (cached && refreshStrategy !== 'load-once') { return cached; } const sources = this.options.sources || ['azure', 'environment', 'local', 'defaults']; for (const source of sources) { try { const config = await this.loadFromSource(source); if (config && Object.keys(config).length > 0) { this.cache.set(cacheKey, config, source); this.configurationLoaded = true; logger.debug(`Configuration loaded from ${source}`); return config; } } catch (error) { logger.warn(`Failed to load from ${source}:`, error); if (source === 'azure' && this.retryCount < this.maxRetries) { this.retryCount++; logger.debug(`Retrying Azure load (${this.retryCount}/${this.maxRetries})`); await delay(Math.pow(2, this.retryCount) * DEFAULT_CONSTANTS.RETRY_DELAY_MS); try { const retryConfig = await this.loadFromSource(source); if (retryConfig && Object.keys(retryConfig).length > 0) { this.cache.set(cacheKey, retryConfig, source); this.configurationLoaded = true; this.retryCount = 0; return retryConfig; } } catch (retryError) { handleError(retryError, ErrorType.CONFIGURATION_ERROR, { source, retryAttempt: this.retryCount, maxRetries: this.maxRetries }, 'warn'); } } continue; } } throw handleError(`Failed to load configuration from all sources: ${sources.join(', ')}`, ErrorType.CONFIGURATION_ERROR, { sources, environment: this.options.environment }); } async loadFromSource(source) { switch (source) { case 'azure': return await this.loadFromAzure(); case 'environment': case 'local': if (!this.localProvider) { this.localProvider = new LocalConfigurationProvider(); } return this.localProvider.getConfiguration(); case CONFIG_SOURCES.DEFAULTS: return getDefaultConfiguration(this.options.environment); default: throw handleError(`Unknown configuration source: ${source}`, ErrorType.VALIDATION_ERROR, { source, availableSources: Object.values(CONFIG_SOURCES) }); } } async loadFromAzure() { if (!this.client) { throw handleError('Azure client not initialized', ErrorType.AZURE_CLIENT_ERROR, { endpoint: this.options.endpoint }); } const config = {}; const keyFilter = this.options.application ? `${this.options.application}:*` : undefined; const labelFilter = this.options.label || (this.options.environment === 'local' ? '\0' : this.options.environment); try { logger.debug(`Loading from Azure - App: ${keyFilter}, Label: ${labelFilter}`); const options = { labelFilter }; if (keyFilter) { options.keyFilter = keyFilter; } const settingsIterator = this.client.listConfigurationSettings(options); let settingsCount = 0; for await (const setting of settingsIterator) { if (setting.key && setting.value !== undefined) { const key = this.options.application ? setting.key.replace(`${this.options.application}:`, '') : setting.key; let resolvedValue = setting.value; if (this.keyVaultConfig.enableKeyVaultReferences && isKeyVaultReference(setting.value)) { try { resolvedValue = await this.resolveKeyVaultReference(setting.value) || setting.value; } catch (error) { logger.warn(`Failed to resolve Key Vault reference for "${key}":`, error); resolvedValue = setting.value; } } setNestedProperty(config, key, resolvedValue); settingsCount++; } } logger.debug(`Loaded ${settingsCount} settings from Azure`); return config; } catch (error) { logger.error('Error loading from Azure:', error); throw error; } } async getValue(key) { const config = await this.getConfiguration(); const keys = key.split('.'); let current = config; for (const k of keys) { if (current && typeof current === 'object' && current !== null && k in current) { current = current[k]; } else { return undefined; } } return current; } async refreshConfiguration(force = false) { const refreshStrategy = this.cache.getConfig().refreshStrategy || 'on-demand'; if (!force && refreshStrategy === 'load-once') { logger.debug('Refresh ignored - using load-once strategy'); return; } this.cache.clear(); this.retryCount = 0; this.configurationLoaded = false; await this.getConfiguration(); } getEnvironment() { return this.options.environment; } getCacheStats() { return this.cache.getStats(); } parseKeyVaultReference(uri) { try { const url = new URL(uri); const pathParts = url.pathname.split('/'); if (pathParts.length < 3 || pathParts[1] !== 'secrets') { return {}; } return { vaultUrl: `${url.protocol}//${url.hostname}`, secretName: pathParts[2], secretVersion: pathParts[3] }; } catch { return {}; } } async resolveKeyVaultReference(keyVaultRef) { try { const ref = JSON.parse(keyVaultRef); if (!ref.uri) return null; const { vaultUrl, secretName, secretVersion } = this.parseKeyVaultReference(ref.uri); if (!vaultUrl || !secretName) { logger.warn('Invalid Key Vault reference format:', keyVaultRef); return null; } const client = this.getKeyVaultClient(vaultUrl); if (!client) return null; const cacheKey = `keyvault:${vaultUrl}:${secretName}:${secretVersion || 'latest'}`; const kvRefreshStrategy = this.keyVaultConfig.refreshStrategy || 'on-demand'; const cachedSecret = this.cache.get(cacheKey); if (cachedSecret) { if (kvRefreshStrategy === 'load-once') { logger.debug(`Using cached Key Vault secret: ${secretName}`); return cachedSecret; } return cachedSecret; } const secret = await this.fetchSecretWithRetry(client, secretName, secretVersion); if (secret) { this.cache.set(cacheKey, secret, 'keyvault'); logger.debug(`Resolved Key Vault secret: ${secretName}`); } return secret; } catch (error) { logger.error('Error resolving Key Vault reference:', error); return null; } } getKeyVaultClient(vaultUrl) { if (!this.credential) { logger.error('No credential available for Key Vault access'); return null; } if (this.keyVaultClients.has(vaultUrl)) { return this.keyVaultClients.get(vaultUrl); } try { const client = new SecretClient(vaultUrl, this.credential); this.keyVaultClients.set(vaultUrl, client); logger.debug(`Created Key Vault client for: ${vaultUrl}`); return client; } catch (error) { logger.error(`Failed to create Key Vault client for ${vaultUrl}:`, error); return null; } } async fetchSecretWithRetry(client, secretName, version) { const maxRetries = this.keyVaultConfig.maxRetries || DEFAULT_CONSTANTS.MAX_RETRIES; const retryDelay = this.keyVaultConfig.retryDelayMs || DEFAULT_CONSTANTS.RETRY_DELAY_MS; for (let attempt = 0; attempt <= maxRetries; attempt++) { try { const secret = version ? await client.getSecret(secretName, { version }) : await client.getSecret(secretName); return secret.value || null; } catch (error) { if (attempt === maxRetries) { if (error.code === 'Forbidden' || error.statusCode === 403) { logger.error(`Access denied to Key Vault secret "${secretName}"`); } else if (error.code === 'SecretNotFound' || error.statusCode === 404) { logger.error(`Key Vault secret "${secretName}" not found`); } else { logger.error(`Error fetching Key Vault secret "${secretName}":`, error); } throw error; } logger.debug(`Key Vault access attempt ${attempt + 1} failed, retrying...`); await delay(retryDelay * Math.pow(2, attempt)); } } return null; } setupPeriodicRefresh() { const cacheConfig = this.cache.getConfig(); const refreshStrategy = cacheConfig.refreshStrategy || 'on-demand'; if (refreshStrategy === 'periodic') { const interval = cacheConfig.refreshInterval || 60 * 60 * 1000; logger.debug(`Setting up periodic refresh every ${interval}ms`); setInterval(async () => { try { logger.debug('Performing periodic configuration refresh'); await this.refreshConfiguration(); } catch (error) { logger.error('Periodic refresh failed:', error); } }, interval); } const kvRefreshStrategy = this.keyVaultConfig.refreshStrategy || 'on-demand'; if (kvRefreshStrategy === 'periodic' && kvRefreshStrategy !== refreshStrategy) { const kvInterval = this.keyVaultConfig.refreshInterval || 30 * 60 * 1000; logger.debug(`Setting up periodic Key Vault refresh every ${kvInterval}ms`); setInterval(async () => { try { logger.debug('Performing periodic Key Vault cache refresh'); this.clearKeyVaultCache(); } catch (error) { logger.error('Periodic Key Vault refresh failed:', error); } }, kvInterval); } } clearKeyVaultCache() { const cacheKeys = this.cache.getAllKeys().filter(key => key.startsWith('keyvault:')); cacheKeys.forEach(key => this.cache.delete(key)); logger.debug(`Cleared ${cacheKeys.length} Key Vault cache entries`); } } class EnhancedCacheManager { constructor(envVarPrefix = 'REACT_APP') { this.environmentSnapshot = null; this.cleanupInterval = null; this.stats = { hits: 0, misses: 0, invalidations: 0, evictions: 0 }; this.envVarPrefix = envVarPrefix; this.layers = new Map(); this.initializeCacheLayers(); this.captureEnvironmentSnapshot(); this.startCleanupInterval(); } initializeCacheLayers() { this.layers.set('azure', { name: 'azure', ttl: 15 * 60 * 1000, maxSize: 100, entries: new Map() }); this.layers.set('env-vars', { name: 'env-vars', ttl: 5 * 60 * 1000, maxSize: 200, entries: new Map() }); this.layers.set('env-files', { name: 'env-files', ttl: 2 * 60 * 1000, maxSize: 50, entries: new Map() }); this.layers.set('merged', { name: 'merged', ttl: 1 * 60 * 1000, maxSize: 100, entries: new Map() }); logger.debug('Initialized enhanced cache layers:', Array.from(this.layers.keys())); } get(key, layerName) { const layer = layerName ? this.layers.get(layerName) : this.findBestLayer(key); if (!layer) { this.stats.misses++; return null; } const entry = layer.entries.get(key); if (!entry) { this.stats.misses++; return null; } if (Date.now() - entry.timestamp > entry.ttl) { layer.entries.delete(key); this.stats.misses++; return null; } if (this.isEnvironmentSensitive(layer.name) && this.hasEnvironmentChanged()) { this.invalidateEnvironmentSensitiveCaches(); this.stats.invalidations++; return null; } this.stats.hits++; return entry.value; } set(key, value, source, layerName, customTtl) { const layer = layerName ? this.layers.get(layerName) : this.selectLayerForSource(source); if (!layer) { logger.warn(`No cache layer found for key "${key}" and source "${source}"`); return; } if (layer.entries.size >= layer.maxSize) { this.evictOldestEntries(layer, Math.floor(layer.maxSize * 0.2)); } const entry = { value, timestamp: Date.now(), ttl: customTtl || layer.ttl, source, hash: this.generateValueHash(value) }; layer.entries.set(key, entry); logger.debug(`Cached "${key}" in layer "${layer.name}" with TTL ${entry.ttl}ms`); } invalidate(pattern, layerName) { let invalidated = 0; if (layerName) { const layer = this.layers.get(layerName); if (layer) { if (pattern) { const regex = new RegExp(pattern); for (const [key] of layer.entries) { if (regex.test(key)) { layer.entries.delete(key); invalidated++; } } } else { invalidated = layer.entries.size; layer.entries.clear(); } } } else { for (const layer of this.layers.values()) { if (pattern) { const regex = new RegExp(pattern); for (const [key] of layer.entries) { if (regex.test(key)) { layer.entries.delete(key); invalidated++; } } } else { invalidated += layer.entries.size; layer.entries.clear(); } } } this.stats.invalidations += invalidated; logger.debug(`Invalidated ${invalidated} cache entries`, { pattern, layerName }); return invalidated; } clear() { for (const layer of this.layers.values()) { layer.entries.clear(); } this.stats.invalidations++; logger.info('Cleared all cache layers'); } checkEnvironmentChanges() { const hasChanged = this.hasEnvironmentChanged(); if (hasChanged) { this.captureEnvironmentSnapshot(); this.invalidateEnvironmentSensitiveCaches(); logger.info('Environment variables changed, invalidated sensitive caches'); } return hasChanged; } getStats() { const layerStats = Array.from(this.layers.entries()).map(([name, layer]) => ({ name, size: layer.entries.size, maxSize: layer.maxSize, ttl: layer.ttl, utilization: Math.round((layer.entries.size / layer.maxSize) * 100) })); return { ...this.stats, hitRate: this.stats.hits + this.stats.misses > 0 ? Math.round((this.stats.hits / (this.stats.hits + this.stats.misses)) * 100) : 0, layers: layerStats, environmentHash: this.environmentSnapshot?.hash, lastEnvironmentUpdate: this.environmentSnapshot?.timestamp }; } warmCache(data) { let warmed = 0; for (const [key, config] of Object.entries(data)) { this.set(key, config.value, config.source, config.layer); warmed++; } logger.info(`Warmed cache with ${warmed} entries`); } destroy() { if (this.cleanupInterval) { clearInterval(this.cleanupInterval); this.cleanupInterval = null; } this.clear(); logger.info('Enhanced cache manager destroyed'); } findBestLayer(key) { if (key.includes('azure')) return this.layers.get('azure'); if (key.includes('env-var')) return this.layers.get('env-vars'); if (key.includes('env-file')) return this.layers.get('env-files'); return this.layers.get('merged'); } selectLayerForSource(source) { switch (source) { case 'azure': return this.layers.get('azure'); case 'app-env-vars': case 'generic-env-vars': case 'process-env-direct': return this.layers.get('env-vars'); case 'app-env-file': case 'root-env-file': return this.layers.get('env-files'); default: return this.layers.get('merged'); } } isEnvironmentSensitive(layerName) { return ['env-vars', 'merged'].includes(layerName); } captureEnvironmentSnapshot() { const relevantVars = {}; const relevantPatterns = [ new RegExp(`^${this.envVarPrefix}_`), /^AZURE_/, /^(NODE_ENV|PORT|DATABASE_URL|API_URL|BASE_URL)$/, /^(OKTA_|AUTH_|JWT_|SESSION_)/, /^[A-Z][A-Z0-9_]*_API(_[A-Z0-9_]*)?/, /^[A-Z][A-Z0-9_]*_URL$/, /^[A-Z][A-Z0-9_]*_KEY$/, /^[A-Z][A-Z0-9_]*_SECRET$/, /^[A-Z][A-Z0-9_]*_TOKEN$/, /^[A-Z][A-Z0-9_]*_HOST$/, /^[A-Z][A-Z0-9_]*_PORT$/, ]; Object.keys(process.env).forEach(key => { const matchesPattern = relevantPatterns.some(pattern => pattern.test(key)); if (matchesPattern) { relevantVars[key] = process.env[key]; } }); const hash = this.generateEnvHash(relevantVars); this.environmentSnapshot = { hash, timestamp: Date.now(), variables: relevantVars }; logger.debug(`Captured environment snapshot with ${Object.keys(relevantVars).length} variables, hash: ${hash}`); } hasEnvironmentChanged() { if (!this.environmentSnapshot) return true; const currentVars = {}; const relevantPatterns = [ new RegExp(`^${this.envVarPrefix}_`), /^AZURE_/, /^(NODE_ENV|PORT|DATABASE_URL|API_URL|BASE_URL)$/, /^(OKTA_|AUTH_|JWT_|SESSION_)/, /^[A-Z][A-Z0-9_]*_API(_[A-Z0-9_]*)?/, /^[A-Z][A-Z0-9_]*_URL$/, /^[A-Z][A-Z0-9_]*_KEY$/, /^[A-Z][A-Z0-9_]*_SECRET$/, /^[A-Z][A-Z0-9_]*_TOKEN$/, /^[A-Z][A-Z0-9_]*_HOST$/, /^[A-Z][A-Z0-9_]*_PORT$/, ]; Object.keys(process.env).forEach(key => { const matchesPattern = relevantPatterns.some(pattern => pattern.test(key)); if (matchesPattern) { currentVars[key] = process.env[key]; } }); const currentHash = this.generateEnvHash(currentVars); return currentHash !== this.environmentSnapshot.hash; } invalidateEnvironmentSensitiveCaches() { const sensitiveLayers = ['env-vars', 'merged']; for (const layerName of sensitiveLayers) { const layer = this.layers.get(layerName); if (layer) { layer.entries.clear(); } } } generateEnvHash(variables) { const sortedKeys = Object.keys(variables).sort(); const content = sortedKeys.map(key => `${key}=${variables[key] || ''}`).join('|'); return this.simpleHash(content); } generateValueHash(value) { return this.simpleHash(JSON.stringify(value)); } simpleHash(str) { let hash = 0; for (let i = 0; i < str.length; i++) { const char = str.charCodeAt(i); hash = ((hash << 5) - hash) + char; hash = hash & hash; } return Math.abs(hash).toString(36); } evictOldestEntries(layer, count) { const entries = Array.from(layer.entries.entries()) .sort(([, a], [, b]) => a.timestamp - b.timestamp) .slice(0, count); for (const [key] of entries) { layer.entries.delete(key); this.stats.evictions++; } logger.debug(`Evicted ${count} oldest entries from layer "${layer.name}"`); } startCleanupInterval() { this.cleanupInterval = setInterval(() => { this.performCleanup(); }, DEFAULT_CONSTANTS.CACHE_CLEANUP_INTERVAL); } performCleanup() { let totalCleaned = 0; const now = Date.now(); for (const layer of this.layers.values()) { const before = layer.entries.size; for (const [key, entry] of layer.entries) { if (now - entry.timestamp > entry.ttl) { layer.entries.delete(key); } } const cleaned = before - layer.entries.size; totalCleaned += cleaned; } this.checkEnvironmentChanges(); if (totalCleaned > 0) { logger.debug(`Cleanup removed ${totalCleaned} expired cache entries`); } } } class AppScopedConfigurationProvider { constructor(basePath = process.cwd(), envVarPrefix = 'REACT_APP') { this.appConfigCache = new Map(); this.discoveredApps = new Set(); this.azureClients = new Map(); this.basePath = basePath; this.envVarPrefix = envVarPrefix; this.cache = new ConfigurationCache({ ttl: DEFAULT_CONSTANTS.MEMORY_CACHE_TTL, storage: ['memory'] }); this.enhancedCache = new EnhancedCacheManager(envVarPrefix); this.discoverAppsFromEnvironment(); } async getAppConfiguration(appId) { try { if (!this.isValidAppId(appId)) { throw new Error(`Invalid app ID: ${appId}`); } const cacheKey = `app-config:${appId}`; const cached = this.enhancedCache.get(cacheKey, 'merged'); if (cached) { return cached; } const config = await this.loadAppConfigurationWithPrecedence(appId); this.enhancedCache.set(cacheKey, config, 'merged', 'merged'); this.cache.set(cacheKey, config); return config; } catch (error) { logger.error(`Failed to load configuration for app "${appId}":`, error); throw error; } } async getAppConfigValue(appId, key) { const config = await this.getAppConfiguration(appId); return this.getNestedValue(config, key); } refreshAppConfiguration(appId) { const cacheKey = `app-config:${appId}`; this.cache.delete(cacheKey); this.appConfigCache.delete(appId); logger.info(`Configuration cache refreshed for app: ${appId}`); } refreshAllConfigurations() { this.cache.clear(); this.appConfigCache.clear(); logger.info('All app configuration