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,295 lines (1,285 loc) • 57.4 kB
JavaScript
import { createContext, useState, useEffect, createElement, useContext, useCallback } from 'react';
import { AppConfigurationClient } from '@azure/app-configuration';
import { ClientSecretCredential, DefaultAzureCredential } from '@azure/identity';
import { SecretClient } from '@azure/keyvault-secrets';
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 RuntimeConfigurationClient {
constructor(options) {
this.azureClient = null;
this.options = {
sources: [CONFIG_SOURCES.AZURE, CONFIG_SOURCES.ENVIRONMENT, CONFIG_SOURCES.LOCAL, CONFIG_SOURCES.DEFAULTS],
precedence: 'first-wins',
useEmbeddedService: true,
configServiceUrl: `http://localhost:${DEFAULT_CONSTANTS.CONFIG_SERVER_PORT}`,
port: DEFAULT_CONSTANTS.CONFIG_SERVER_PORT,
...options
};
this.appId = this.options.appId;
this.cache = new ConfigurationCache(options.cache);
this.serviceUrl = this.options.configServiceUrl || `http://localhost:${this.options.port || DEFAULT_CONSTANTS.CONFIG_SERVER_PORT}`;
if (!this.options.useEmbeddedService && this.options.endpoint) {
this.azureClient = new AzureConfigurationClient(options);
}
}
buildConfigEndpoint() {
return '';
}
buildConfigValueEndpoint(key) {
return '';
}
buildRefreshEndpoint() {
return '/refresh';
}
buildCacheKey(suffix = '') {
const environmentKey = this.appId
? `${this.appId}:${this.options.environment}`
: this.options.environment;
return suffix ? `${suffix}:${environmentKey}` : `config:${environmentKey}`;
}
async getConfiguration() {
const cacheKey = this.buildCacheKey();
const cached = this.cache.get(cacheKey);
if (cached) {
return cached;
}
let config;
let source;
try {
if (this.options.useEmbeddedService) {
console.debug(`[react-azure-config] Fetching config from: ${this.serviceUrl}`);
const response = await this.fetchFromService(this.buildConfigEndpoint());
console.debug(`[react-azure-config] API response status: ${response.success ? 'success' : 'error'}`, response);
if (response.config) {
config = response.config;
}
else if (response.data) {
config = response.data;
}
else {
config = {};
}
source = response.source || 'api';
if (Object.keys(config).length === 0) {
console.debug('[react-azure-config] API returned empty config, trying environment variable fallback');
config = this.getEnvironmentFallback();
source = 'environment-fallback';
console.debug(`[react-azure-config] Environment fallback provided ${Object.keys(config).length} variables for app "${this.appId}"`);
if (Object.keys(config).length === 0) {
console.warn(`[react-azure-config] No configuration found from API or environment fallback for app "${this.appId}"`);
}
}
}
else {
if (!this.azureClient) {
throw handleError('Azure client not initialized', ErrorType.AZURE_CLIENT_ERROR, { useEmbeddedService: false });
}
config = await this.azureClient.getConfiguration();
source = 'azure';
}
this.cache.set(cacheKey, config, source);
return config;
}
catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.debug(`[react-azure-config] API fetch failed for app "${this.appId}": ${errorMessage}. Activating fallback to environment variables.`);
try {
config = this.getEnvironmentFallback();
source = 'environment-fallback';
if (Object.keys(config).length > 0) {
console.debug(`[react-azure-config] Successfully loaded ${Object.keys(config).length} variables from environment fallback for app "${this.appId}"`);
this.cache.set(cacheKey, config, source);
return config;
}
else {
console.warn(`[react-azure-config] Environment fallback returned no variables for app "${this.appId}"`);
}
}
catch (envError) {
const envErrorMessage = envError instanceof Error ? envError.message : String(envError);
console.error(`[react-azure-config] Environment fallback also failed for app "${this.appId}": ${envErrorMessage}`);
}
handleError(error, ErrorType.CONFIGURATION_ERROR, {
useEmbeddedService: this.options.useEmbeddedService,
serviceUrl: this.serviceUrl,
appId: this.appId
});
const staleCache = this.cache.get(cacheKey);
if (staleCache) {
logger.warn('Returning stale cached configuration due to error', { appId: this.appId });
return staleCache;
}
const defaultConfig = getDefaultConfiguration(this.options.environment);
this.cache.set(cacheKey, defaultConfig, 'defaults');
return defaultConfig;
}
}
async getValue(key) {
try {
const config = await this.getConfiguration();
const value = getNestedProperty(config, key);
if (value === undefined) {
console.debug(`[react-azure-config] Key "${key}" not found in config, trying direct environment lookup`);
const envValue = this.getEnvironmentValue(key);
if (envValue !== undefined) {
console.debug(`[react-azure-config] Found value for "${key}" in environment variables`);
return envValue;
}
}
return value;
}
catch (error) {
console.debug(`[react-azure-config] Failed to get config value for key "${key}", trying environment fallback:`, error);
try {
const envValue = this.getEnvironmentValue(key);
if (envValue !== undefined) {
console.debug(`[react-azure-config] Successfully retrieved "${key}" from environment variables`);
return envValue;
}
}
catch (envError) {
console.error(`[react-azure-config] Environment fallback failed for key "${key}":`, envError);
}
logger.error(`Failed to get config value for key "${key}":`, error, { appId: this.appId });
return undefined;
}
}
async refreshConfiguration() {
try {
if (this.options.useEmbeddedService) {
await this.fetchFromService(this.buildRefreshEndpoint(), { method: 'POST' });
}
else if (this.azureClient) {
await this.azureClient.refreshConfiguration();
}
this.cache.clear();
}
catch (error) {
logger.error('Failed to refresh configuration:', error, { appId: this.appId });
throw error;
}
}
async fetchFromService(endpoint, options = {}) {
const url = endpoint ? `${this.serviceUrl}${endpoint}` : this.serviceUrl;
console.debug(`[react-azure-config] Making API request to: ${url}`);
const response = await fetch(url, {
headers: {
'Content-Type': 'application/json',
...options.headers
},
...options
});
console.debug(`[react-azure-config] API response status: ${response.status} ${response.statusText}`);
if (!response.ok) {
throw handleError(`Config service error: ${response.status} ${response.statusText}`, ErrorType.SERVER_ERROR, { url, status: response.status, statusText: response.statusText });
}
const data = await response.json();
if (!data.success && data.error) {
throw handleError(`Config service error: ${data.error}`, ErrorType.SERVER_ERROR, { endpoint, apiError: data.error });
}
return data;
}
getEnvironmentFallback() {
const config = {};
const patterns = [
this.appId ? `REACT_APP_${this.appId.toUpperCase().replace(/-/g, '_')}_` : null,
this.appId ? `${this.appId.toUpperCase().replace(/-/g, '_')}_` : null,
'REACT_APP_',
'NEXTAUTH_',
'OKTA_',
'AZURE_',
'AUTH_',
'API_',
'DATABASE_',
'SGJ_'
].filter(Boolean);
Object.keys(process.env).forEach(key => {
const value = process.env[key];
if (value !== undefined) {
const matchesPattern = patterns.some(pattern => key.startsWith(pattern));
if (matchesPattern) {
const transformedKey = this.transformEnvironmentKey(key);
config[transformedKey] = value;
config[key] = value;
if (key.includes('NEXTAUTH_SECRET')) {
config['nextauth.secret'] = value;
config['nextauthsecret'] = value;
}
if (key.includes('OKTA_CLIENT_ID')) {
config['okta.client.id'] = value;
config['oktaclientid'] = value;
}
}
}
});
const commonKeys = [
'NEXTAUTH_SECRET',
'OKTA_CLIENT_ID',
'OKTA_CLIENT_SECRET',
'OKTA_ISSUER',
'SGJ_INVESTMENT_BASE_URL',
'API_URL',
'DATABASE_URL'
];
commonKeys.forEach(key => {
if (!config[key]) {
const value = this.getEnvironmentValue(key);
if (value !== undefined) {
const nestedKey = key.toLowerCase().replace(/_/g, '.');
config[nestedKey] = value;
config[key] = value;
}
}
});
console.debug(`[react-azure-config] Environment fallback loaded ${Object.keys(config).length} variables:`, Object.keys(config));
return config;
}
transformEnvironmentKey(key) {
let result = key;
if (this.appId) {
const appIdUpper = this.appId.toUpperCase().replace(/-/g, '_');
const appPrefixes = [
`REACT_APP_${appIdUpper}_`,
`${appIdUpper}_`
];
for (const prefix of appPrefixes) {
if (result.startsWith(prefix)) {
result = result.substring(prefix.length);
break;
}
}
}
const genericPrefixes = ['REACT_APP_'];
for (const prefix of genericPrefixes) {
if (result.startsWith(prefix)) {
result = result.substring(prefix.length);
break;
}
}
return result.toLowerCase().replace(/_/g, '.');
}
getEnvironmentValue(key) {
if (typeof process === 'undefined') {
return undefined;
}
const patterns = [
key,
`REACT_APP_${key}`,
this.appId ? `REACT_APP_${this.appId.toUpperCase()}_${key}` : null,
this.appId ? `${this.appId.toUpperCase()}_${key}` : null
].filter(Boolean);
for (const pattern of patterns) {
const value = process.env[pattern];
if (value !== undefined) {
console.debug(`[react-azure-config] Found environment variable: ${pattern}=${value}`);
return value;
}
}
return undefined;
}
getEnvironment() {
return this.options.environment;
}
getAppId() {
return this.appId;
}
getCacheStats() {
return this.cache.getStats();
}
isUsingEmbeddedService() {
return this.options.useEmbeddedService || false;
}
async checkServiceHealth() {
if (this.options.useEmbeddedService) {
try {
const response = await this.fetchFromService('/health');
return response.data;
}
catch (error) {
return {
status: 'unhealthy',
error: error.message
};
}
}
else {
return {
status: 'not-applicable',
reason: 'Direct mode'
};
}
}
getServiceUrl() {
return this.serviceUrl;
}
}
const createRuntimeConfigClient = (options) => {
return new RuntimeConfigurationClient(options);
};
const ConfigContext = createContext(null);
const ConfigProvider = ({ children, client: providedClient, apiUrl, appId, fetchOnMount = true }) => {
const [client] = useState(() => {
const getEnvironment = () => {
try {
return process.env.NODE_ENV || 'production';
}
catch {