UNPKG

prodobit

Version:

Open-core business application development platform

268 lines (230 loc) 7.04 kB
import { createPlatformAdapter, type AdapterOptions, } from "../platform/adapter-factory.js"; import type { PlatformAdapter } from "../platform/adapters.js"; import { supportsFileSystem } from "../platform/runtime-detection.js"; import { type ProdobitConfig } from "../schemas/index.js"; import { validateProdobitConfig } from "../schemas/type.js"; import { DefaultSource, EnvironmentSource, FileSource, } from "../sources/index.js"; import type { ConfigLoadOptions, ConfigSource, ConfigValidationErrorInfo, ConfigValidationResult, } from "../types/config-source.js"; export interface ConfigLoaderOptions extends ConfigLoadOptions, AdapterOptions {} export class ConfigLoader { private sources: ConfigSource[] = []; private loadedConfig: ProdobitConfig | null = null; private watchers: Array<() => Promise<void>> = []; private adapter: PlatformAdapter; constructor(options: ConfigLoaderOptions = {}) { this.adapter = createPlatformAdapter(options); this.initializeDefaultSources(options); if (options.sources) { this.sources.push(...options.sources); } // Sort sources by priority (highest first) this.sources.sort((a, b) => b.priority - a.priority); } async load(): Promise<ProdobitConfig> { const mergedConfig = await this.loadAndMergeConfigs(); const validationResult = this.validateConfig(mergedConfig); if (!validationResult.success) { const errorDetails = validationResult.errors .map((error) => `${error.path}: ${error.message}`) .join("\n"); throw new Error(`Configuration validation failed:\n${errorDetails}`); } this.loadedConfig = validationResult.data as ProdobitConfig; return this.loadedConfig; } async watch(callback: (config: ProdobitConfig) => void): Promise<void> { for (const source of this.sources) { if (source.watch) { const watchCallback = async (sourceConfig: Record<string, unknown>) => { try { const newConfig = await this.load(); callback(newConfig); } catch (error) { console.error( `Error reloading configuration from ${source.name}:`, error ); } }; await source.watch(watchCallback); if (source.close) { this.watchers.push(() => source.close!()); } } } } async close(): Promise<void> { await Promise.all(this.watchers.map((close) => close())); this.watchers = []; } getLoadedConfig(): ProdobitConfig | null { return this.loadedConfig; } getAdapter(): PlatformAdapter { return this.adapter; } addSource(source: ConfigSource): void { this.sources.push(source); this.sources.sort((a, b) => b.priority - a.priority); } private initializeDefaultSources(options: ConfigLoaderOptions): void { // Default configuration (lowest priority) this.sources.push(new DefaultSource()); // Configuration files (only if file system is supported) if (supportsFileSystem()) { const configFiles = [ "prodobit.config.json", "prodobit.config.js", ".prodobitrc.json", ".prodobitrc.js", ]; for (const configFile of configFiles) { this.sources.push( new FileSource(this.adapter, { path: configFile, required: false, }) ); } // Environment-specific config file if (options.environment) { this.sources.push( new FileSource(this.adapter, { path: `prodobit.config.${options.environment}.json`, required: false, }) ); } } // Environment variables (highest priority) this.sources.push(new EnvironmentSource(this.adapter)); } private async loadAndMergeConfigs(): Promise<Record<string, unknown>> { let mergedConfig: Record<string, unknown> = {}; // Load from sources in reverse priority order (lowest to highest) const reversedSources = [...this.sources].reverse(); for (const source of reversedSources) { try { const sourceConfig = await source.load(); if (sourceConfig) { mergedConfig = this.deepMerge(mergedConfig, sourceConfig); } } catch (error) { console.error( `Error loading configuration from ${source.name}:`, error ); throw error; } } return mergedConfig; } private validateConfig( config: Record<string, unknown> ): ConfigValidationResult { try { const result = validateProdobitConfig(config); // Check if result is an ArkErrors object (failed validation) if (result.constructor.name === "ArkErrors") { // Convert ArkErrors to string to get error messages return { success: false, errors: [ { path: "validation", message: result.toString(), value: config, }, ], }; } // For arktype 2.x, successful validation returns the validated data directly return { success: true, errors: [], data: result, }; } catch (error) { return { success: false, errors: [ { path: "root", message: error instanceof Error ? error.message : "Unknown validation error", value: config, }, ], }; } } private parseArkTypeError(error: Error): ConfigValidationErrorInfo[] { // Parse ArkType validation errors const errorMessage = error.message; const errors: ConfigValidationErrorInfo[] = []; // This is a simplified parser - ArkType error format may vary const lines = errorMessage.split("\n"); for (const line of lines) { const trimmedLine = line.trim(); if ( trimmedLine && !trimmedLine.startsWith("at") && !trimmedLine.startsWith("Expected") ) { errors.push({ path: "unknown", message: trimmedLine, }); } } if (errors.length === 0) { errors.push({ path: "root", message: errorMessage, }); } return errors; } private deepMerge( target: Record<string, unknown>, source: Record<string, unknown> ): Record<string, unknown> { const result = { ...target }; for (const [key, value] of Object.entries(source)) { if (value === null || value === undefined) { continue; } if ( typeof value === "object" && !Array.isArray(value) && typeof result[key] === "object" && !Array.isArray(result[key]) && result[key] !== null ) { result[key] = this.deepMerge( result[key] as Record<string, unknown>, value as Record<string, unknown> ); } else { result[key] = value; } } return result; } }