import { parse } from "dotenv"; import { existsSync, globSync, watch, readFileSync } from "node:fs"; import { readFile } from "node:fs/promises"; import { resolve, join, basename } from "node:path"; export type EnvChangeCallback = (changedKeys: string[]) => void; export interface EnvManagerConfig { /** * Directory to search for .env files * Defaults to process.cwd() */ cwd?: string; /** * Explicit env file paths to load * Key is the identifier (e.g., "global", "app1") * Value is the file path relative to cwd or absolute */ files?: Record; /** * Enable file watching for env files * Defaults to false */ watch?: boolean; } export class EnvManager { private env: Map> = new Map(); private cwd: string; private watchEnabled: boolean; private watchers: Map> = new Map(); private fileToKeys: Map> = new Map(); private changeCallbacks: Set = new Set(); private reloadDebounceTimers: Map> = new Map(); constructor(config: EnvManagerConfig = {}) { this.cwd = config.cwd ?? process.cwd(); this.watchEnabled = config.watch ?? false; // Load .env and .env.* files from cwd this.loadEnvFilesFromCwd(); // Load explicitly specified files if (config.files) { for (const [key, filePath] of Object.entries(config.files)) { this.loadEnvFile(key, filePath); } } } registerFile(key: string, filePath: string): void { this.loadEnvFile(key, filePath); } getEnvForKey(key: string): Record { return this.env.get(key) ?? {}; } /** * Load .env and .env.* files from the cwd */ private loadEnvFilesFromCwd(): void { // Load .env file as global const dotEnvPath = resolve(this.cwd, ".env"); if (existsSync(dotEnvPath)) { this.loadEnvFile("global", dotEnvPath); } // Load .env.* files try { const pattern = join(this.cwd, ".env.*"); const envFiles = globSync(pattern); for (const filePath of envFiles) { // Extract the suffix after .env. const fileName = basename(filePath); const match = fileName.match(/^\.env\.(.+)$/); if (match) { const suffix = match[1]; this.loadEnvFile(suffix, filePath); } } } catch (err) { console.warn("Failed to scan env files:", err); } } /** * Load a single env file and store it in the map */ private loadEnvFile(key: string, filePath: string): void { const absolutePath = resolve(this.cwd, filePath); if (!existsSync(absolutePath)) { return; // Silently skip non-existent files } try { const content = readFileSync(absolutePath, "utf-8"); const parsed = parse(content); this.env.set(key, parsed); // Track which file maps to which key if (!this.fileToKeys.has(absolutePath)) { this.fileToKeys.set(absolutePath, new Set()); } this.fileToKeys.get(absolutePath)!.add(key); // Start watching if enabled and not already watching if (this.watchEnabled && !this.watchers.has(absolutePath)) { this.watchFile(absolutePath); } } catch (err) { console.warn(`Failed to load env file: ${absolutePath}`, err); } } /** * Watch a file for changes */ private watchFile(absolutePath: string): void { try { const watcher = watch(absolutePath, (eventType) => { if (eventType === "change") { this.handleFileChange(absolutePath); } }); this.watchers.set(absolutePath, watcher); } catch (err) { console.warn(`Failed to watch env file: ${absolutePath}`, err); } } /** * Handle file change with debouncing */ private handleFileChange(absolutePath: string): void { // Clear existing timer if any const existingTimer = this.reloadDebounceTimers.get(absolutePath); if (existingTimer) { clearTimeout(existingTimer); } // Debounce reload by 100ms to avoid multiple rapid reloads const timer = setTimeout(() => { this.reloadFile(absolutePath); this.reloadDebounceTimers.delete(absolutePath); }, 100); this.reloadDebounceTimers.set(absolutePath, timer); } /** * Reload a file and notify callbacks */ private reloadFile(absolutePath: string): void { const keys = this.fileToKeys.get(absolutePath); if (!keys) return; readFile(absolutePath, "utf-8") .then((content) => parse(content)) .then((parsed) => { const changedKeys: string[] = []; for (const key of keys) { this.env.set(key, parsed); changedKeys.push(key); } // Notify all callbacks if (changedKeys.length > 0) { for (const callback of this.changeCallbacks) { callback(changedKeys); } } }) .catch((err) => { console.warn(`Failed to reload env file: ${absolutePath}`, err); }); } /** * Register a callback to be called when env files change * Returns a function to unregister the callback */ onChange(callback: EnvChangeCallback): () => void { this.changeCallbacks.add(callback); return () => { this.changeCallbacks.delete(callback); }; } /** * Stop watching all files and cleanup */ dispose(): void { // Clear all timers for (const timer of this.reloadDebounceTimers.values()) { clearTimeout(timer); } this.reloadDebounceTimers.clear(); // Close all watchers for (const watcher of this.watchers.values()) { watcher.close(); } this.watchers.clear(); // Clear callbacks this.changeCallbacks.clear(); } /** * Get environment variables for a specific process * Merges global env with process-specific env * Process-specific env variables override global ones */ getEnvVars(processKey?: string): Record { const globalEnv = this.env.get("global") ?? {}; if (!processKey) { return { ...globalEnv }; } const processEnv = this.env.get(processKey) ?? {}; return { ...globalEnv, ...processEnv }; } /** * Get all loaded env maps (for debugging/inspection) */ getAllEnv(): ReadonlyMap> { return this.env; } }