made config async

This commit is contained in:
Boki 2025-06-20 20:47:31 -04:00
parent 92d4b90987
commit caf1c5fcaf
6 changed files with 91 additions and 143 deletions

View file

@ -1,7 +1,7 @@
// Framework imports // Framework imports
import { Hono } from 'hono'; import { Hono } from 'hono';
import { cors } from 'hono/cors'; import { cors } from 'hono/cors';
import { initializeConfigSync } from '@stock-bot/config'; import { initializeConfig } from '@stock-bot/config';
// Library imports // Library imports
import { getLogger, setLoggerConfig, shutdownLoggers } from '@stock-bot/logger'; import { getLogger, setLoggerConfig, shutdownLoggers } from '@stock-bot/logger';
import { connectMongoDB } from '@stock-bot/mongodb-client'; import { connectMongoDB } from '@stock-bot/mongodb-client';
@ -12,7 +12,8 @@ import { ProxyManager } from '@stock-bot/utils';
// Local imports // Local imports
import { exchangeRoutes, healthRoutes, queueRoutes } from './routes'; import { exchangeRoutes, healthRoutes, queueRoutes } from './routes';
const config = initializeConfigSync(); const config = initializeConfig();
console.log('Configuration loaded:', config);
const serviceConfig = config.service; const serviceConfig = config.service;
const databaseConfig = config.database; const databaseConfig = config.database;
const queueConfig = config.queue; const queueConfig = config.queue;

View file

@ -33,9 +33,9 @@ export class ConfigManager<T = Record<string, unknown>> {
} }
/** /**
* Initialize the configuration by loading from all sources * Initialize the configuration by loading from all sources synchronously.
*/ */
async initialize(schema?: ConfigSchema): Promise<T> { initialize(schema?: ConfigSchema): T {
if (this.config) { if (this.config) {
return this.config; return this.config;
} }
@ -48,7 +48,8 @@ export class ConfigManager<T = Record<string, unknown>> {
// Load configurations from all sources // Load configurations from all sources
const configs: Record<string, unknown>[] = []; const configs: Record<string, unknown>[] = [];
for (const loader of sortedLoaders) { for (const loader of sortedLoaders) {
const config = await loader.load(); // Assuming all loaders now have a synchronous `load` method
const config = loader.load();
if (config && Object.keys(config).length > 0) { if (config && Object.keys(config).length > 0) {
configs.push(config); configs.push(config);
} }
@ -82,51 +83,6 @@ export class ConfigManager<T = Record<string, unknown>> {
return this.config; return this.config;
} }
/**
* Initialize the configuration synchronously (only env vars, no file loading)
*/
initializeSync(schema?: ConfigSchema): T {
if (this.config) {
return this.config;
}
this.schema = schema;
// Only use EnvLoader for sync initialization
const envLoader = this.loaders.find(loader => loader.constructor.name === 'EnvLoader');
if (!envLoader) {
throw new ConfigError('No EnvLoader found for synchronous initialization');
}
// Load env vars synchronously
const envLoaderInstance = envLoader as any;
const config = envLoaderInstance.loadSync ? envLoaderInstance.loadSync() : {};
// Add environment if not present
if (typeof config === 'object' && config !== null && !('environment' in config)) {
(config as Record<string, unknown>)['environment'] = this.environment;
}
// Validate if schema provided
if (this.schema) {
try {
this.config = this.schema.parse(config) as T;
} catch (error) {
if (error instanceof z.ZodError) {
throw new ConfigValidationError(
'Configuration validation failed',
error.errors
);
}
throw error;
}
} else {
this.config = config as T;
}
return this.config;
}
/** /**
* Get the current configuration * Get the current configuration
*/ */
@ -176,7 +132,7 @@ export class ConfigManager<T = Record<string, unknown>> {
throw new ConfigError('Configuration not initialized. Call initialize() first.'); throw new ConfigError('Configuration not initialized. Call initialize() first.');
} }
const updated = this.deepMerge(this.config as any, updates as any) as T; const updated = this.deepMerge(this.config as Record<string, unknown>, updates as Record<string, unknown>) as T;
// Re-validate if schema is present // Re-validate if schema is present
if (this.schema) { if (this.schema) {
@ -240,8 +196,8 @@ export class ConfigManager<T = Record<string, unknown>> {
} }
} }
private deepMerge(...objects: Record<string, any>[]): Record<string, any> { private deepMerge(...objects: Record<string, unknown>[]): Record<string, unknown> {
const result: Record<string, any> = {}; const result: Record<string, unknown> = {};
for (const obj of objects) { for (const obj of objects) {
for (const [key, value] of Object.entries(obj)) { for (const [key, value] of Object.entries(obj)) {
@ -253,7 +209,10 @@ export class ConfigManager<T = Record<string, unknown>> {
!(value instanceof Date) && !(value instanceof Date) &&
!(value instanceof RegExp) !(value instanceof RegExp)
) { ) {
result[key] = this.deepMerge(result[key] || {}, value); result[key] = this.deepMerge(
(result[key] as Record<string, unknown>) || {} as Record<string, unknown>,
value as Record<string, unknown>
);
} else { } else {
result[key] = value; result[key] = value;
} }

View file

@ -1,28 +1,8 @@
// Export all schemas
export * from './schemas';
// Export types
export * from './types';
// Export errors
export * from './errors';
// Export loaders
export { EnvLoader } from './loaders/env.loader';
export { FileLoader } from './loaders/file.loader';
// Export ConfigManager
export { ConfigManager } from './config-manager';
// Export utilities
export * from './utils/secrets';
export * from './utils/validation';
// Import necessary types for singleton // Import necessary types for singleton
import { EnvLoader } from './loaders/env.loader';
import { FileLoader } from './loaders/file.loader';
import { ConfigManager } from './config-manager'; import { ConfigManager } from './config-manager';
import { AppConfig, appConfigSchema } from './schemas'; import { AppConfig, appConfigSchema } from './schemas';
import { FileLoader } from './loaders/file.loader';
import { EnvLoader } from './loaders/env.loader';
// Create singleton instance // Create singleton instance
let configInstance: ConfigManager<AppConfig> | null = null; let configInstance: ConfigManager<AppConfig> | null = null;
@ -37,30 +17,36 @@ function loadCriticalEnvVarsSync(): void {
if (fs.existsSync(envPath)) { if (fs.existsSync(envPath)) {
const envContent = fs.readFileSync(envPath, 'utf-8'); const envContent = fs.readFileSync(envPath, 'utf-8');
const lines = envContent.split('\n'); const lines = envContent.split('\n');
for (const line of lines) { for (const line of lines) {
const trimmed = line.trim(); const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue; if (!trimmed || trimmed.startsWith('#')) {
continue;
}
const equalIndex = trimmed.indexOf('='); const equalIndex = trimmed.indexOf('=');
if (equalIndex === -1) continue; if (equalIndex === -1) {
continue;
}
const key = trimmed.substring(0, equalIndex).trim(); const key = trimmed.substring(0, equalIndex).trim();
let value = trimmed.substring(equalIndex + 1).trim(); let value = trimmed.substring(equalIndex + 1).trim();
// Remove surrounding quotes // Remove surrounding quotes
if ((value.startsWith('"') && value.endsWith('"')) || if (
(value.startsWith("'") && value.endsWith("'"))) { (value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1); value = value.slice(1, -1);
} }
// Only set if not already set // Only set if not already set
if (!(key in process.env)) { if (!(key in process.env)) {
process.env[key] = value; process.env[key] = value;
} }
} }
} }
} catch (error) { } catch {
// Ignore errors - env file is optional // Ignore errors - env file is optional
} }
} }
@ -69,26 +55,16 @@ function loadCriticalEnvVarsSync(): void {
loadCriticalEnvVarsSync(); loadCriticalEnvVarsSync();
/** /**
* Initialize configuration synchronously (env vars only) * Initialize the global configuration synchronously.
* This should be called at the very start of the application *
* This loads configuration from all sources in the correct hierarchy:
* 1. Schema defaults (lowest priority)
* 2. default.json
* 3. [environment].json (e.g., development.json)
* 4. .env file values
* 5. process.env values (highest priority)
*/ */
export function initializeConfigSync(): AppConfig { export function initializeConfig(configPath?: string): AppConfig {
if (!configInstance) {
configInstance = new ConfigManager<AppConfig>({
loaders: [
new EnvLoader(''), // Environment variables only for sync
]
});
}
return configInstance.initializeSync(appConfigSchema);
}
/**
* Initialize the global configuration
*/
export async function initializeConfig(
configPath?: string
): Promise<AppConfig> {
if (!configInstance) { if (!configInstance) {
configInstance = new ConfigManager<AppConfig>({ configInstance = new ConfigManager<AppConfig>({
configPath, configPath,
@ -98,21 +74,21 @@ export async function initializeConfig(
} }
/** /**
* Initialize configuration for a service in a monorepo * Initialize configuration for a service in a monorepo.
* Automatically loads configs from: * Automatically loads configs from:
* 1. Root config directory (../../config) * 1. Root config directory (../../config)
* 2. Service-specific config directory (./config) * 2. Service-specific config directory (./config)
* 3. Environment variables * 3. Environment variables
*/ */
export async function initializeServiceConfig(): Promise<AppConfig> { export function initializeServiceConfig(): AppConfig {
if (!configInstance) { if (!configInstance) {
const environment = process.env.NODE_ENV || 'development'; const environment = process.env.NODE_ENV || 'development';
configInstance = new ConfigManager<AppConfig>({ configInstance = new ConfigManager<AppConfig>({
loaders: [ loaders: [
new FileLoader('../../config', environment), // Root config new FileLoader('../../config', environment), // Root config
new FileLoader('./config', environment), // Service config new FileLoader('./config', environment), // Service config
new EnvLoader(''), // Environment variables new EnvLoader(''), // Environment variables
] ],
}); });
} }
return configInstance.initialize(appConfigSchema); return configInstance.initialize(appConfigSchema);
@ -185,4 +161,24 @@ export function isProduction(): boolean {
export function isTest(): boolean { export function isTest(): boolean {
return getConfig().environment === 'test'; return getConfig().environment === 'test';
} }
// Export all schemas
export * from './schemas';
// Export types
export * from './types';
// Export errors
export * from './errors';
// Export loaders
export { EnvLoader } from './loaders/env.loader';
export { FileLoader } from './loaders/file.loader';
// Export ConfigManager
export { ConfigManager } from './config-manager';
// Export utilities
export * from './utils/secrets';
export * from './utils/validation';

View file

@ -25,11 +25,7 @@ export class EnvLoader implements ConfigLoader {
}; };
} }
async load(): Promise<Record<string, unknown>> { load(): Record<string, unknown> {
return this.loadSync();
}
loadSync(): Record<string, unknown> {
try { try {
// Load root .env file - try multiple possible locations // Load root .env file - try multiple possible locations
const possiblePaths = ['./.env', '../.env', '../../.env']; const possiblePaths = ['./.env', '../.env', '../../.env'];
@ -67,7 +63,7 @@ export class EnvLoader implements ConfigLoader {
} }
} }
private setConfigValue(config: Record<string, any>, key: string, value: string): void { private setConfigValue(config: Record<string, unknown>, key: string, value: string): void {
const parsedValue = this.parseValue(value); const parsedValue = this.parseValue(value);
try { try {
@ -104,7 +100,7 @@ export class EnvLoader implements ConfigLoader {
} }
} }
private setNestedValue(obj: Record<string, any>, path: string[], value: unknown): boolean { private setNestedValue(obj: Record<string, unknown>, path: string[], value: unknown): boolean {
if (path.length === 0) { if (path.length === 0) {
return false; // Cannot set value on empty path return false; // Cannot set value on empty path
} }
@ -115,13 +111,13 @@ export class EnvLoader implements ConfigLoader {
try { try {
const target = path.reduce((acc, key) => { const target = path.reduce((acc, key) => {
if (!acc[key]) { if (!acc[key] || typeof acc[key] !== 'object') {
acc[key] = {}; acc[key] = {};
} }
return acc[key]; return acc[key] as Record<string, unknown>;
}, obj); }, obj);
target[lastKey] = value; (target as Record<string, unknown>)[lastKey] = value;
return true; return true;
} catch { } catch {
// If we can't assign to any property (readonly), skip this env var silently // If we can't assign to any property (readonly), skip this env var silently
@ -257,11 +253,11 @@ export class EnvLoader implements ConfigLoader {
process.env[key] = value; process.env[key] = value;
} }
} }
} catch (error: any) { } catch (error: unknown) {
// File not found is not an error (env files are optional) // File not found is not an error (env files are optional)
if (error.code !== 'ENOENT') { if (error && typeof error === 'object' && 'code' in error && error.code !== 'ENOENT') {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.warn(`Warning: Could not load env file ${filePath}:`, error.message); console.warn(`Warning: Could not load env file ${filePath}:`, error instanceof Error ? error.message : String(error));
} }
} }
} }

View file

@ -1,4 +1,4 @@
import { readFile } from 'fs/promises'; import { readFileSync, existsSync } from 'fs';
import { join } from 'path'; import { join } from 'path';
import { ConfigLoader } from '../types'; import { ConfigLoader } from '../types';
import { ConfigLoaderError } from '../errors'; import { ConfigLoaderError } from '../errors';
@ -11,18 +11,18 @@ export class FileLoader implements ConfigLoader {
private environment: string private environment: string
) {} ) {}
async load(): Promise<Record<string, unknown>> { load(): Record<string, unknown> {
try { try {
const configs: Record<string, unknown>[] = []; const configs: Record<string, unknown>[] = [];
// Load default config // Load default config
const defaultConfig = await this.loadFile('default.json'); const defaultConfig = this.loadFile('default.json');
if (defaultConfig) { if (defaultConfig) {
configs.push(defaultConfig); configs.push(defaultConfig);
} }
// Load environment-specific config // Load environment-specific config
const envConfig = await this.loadFile(`${this.environment}.json`); const envConfig = this.loadFile(`${this.environment}.json`);
if (envConfig) { if (envConfig) {
configs.push(envConfig); configs.push(envConfig);
} }
@ -37,30 +37,26 @@ export class FileLoader implements ConfigLoader {
} }
} }
private async loadFile(filename: string): Promise<Record<string, unknown> | null> { private loadFile(filename: string): Record<string, unknown> | null {
const filepath = join(this.configPath, filename); const filepath = join(this.configPath, filename);
try { if (!existsSync(filepath)) {
const content = await readFile(filepath, 'utf-8'); return null;
return JSON.parse(content);
} catch (error: unknown) {
// File not found is not an error (configs are optional)
if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') {
return null;
}
throw error;
} }
const content = readFileSync(filepath, 'utf-8');
return JSON.parse(content);
} }
private deepMerge(...objects: Record<string, any>[]): Record<string, any> { private deepMerge(...objects: Record<string, unknown>[]): Record<string, unknown> {
const result: Record<string, any> = {}; const result: Record<string, unknown> = {};
for (const obj of objects) { for (const obj of objects) {
for (const [key, value] of Object.entries(obj)) { for (const [key, value] of Object.entries(obj)) {
if (value === null || value === undefined) { if (value === null || value === undefined) {
result[key] = value; result[key] = value;
} else if (typeof value === 'object' && !Array.isArray(value)) { } else if (typeof value === 'object' && !Array.isArray(value) && value !== null) {
result[key] = this.deepMerge(result[key] || {}, value); result[key] = this.deepMerge(result[key] as Record<string, unknown> || {}, value as Record<string, unknown>);
} else { } else {
result[key] = value; result[key] = value;
} }

View file

@ -3,7 +3,7 @@ import { z } from 'zod';
export type Environment = 'development' | 'test' | 'production'; export type Environment = 'development' | 'test' | 'production';
export interface ConfigLoader { export interface ConfigLoader {
load(): Promise<Record<string, unknown>>; load(): Record<string, unknown>;
readonly priority: number; readonly priority: number;
} }