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
JavaScript
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