/** * DNA-006: Config Hot Reload * * 설정 파일 변경 감시 및 핫 리로드 * - 디바운스로 연속 변경 병합 * - 에러 발생 시 기존 설정 유지 * - 클린업 함수 반환 */ import { watch, type FSWatcher } from "fs"; import { loadManduConfig, type ManduConfig } from "./mandu.js"; /** * 설정 변경 이벤트 타입 */ export type ConfigChangeEvent = { /** 이전 설정 */ previous: ManduConfig; /** 새 설정 */ current: ManduConfig; /** 변경된 파일 경로 */ path: string; /** 변경 시간 */ timestamp: Date; }; /** * 설정 감시 옵션 */ export interface WatchConfigOptions { /** 디바운스 딜레이 (ms, 기본: 100) */ debounceMs?: number; /** 초기 로드 시에도 콜백 호출 (기본: false) */ immediate?: boolean; /** 에러 핸들러 */ onError?: (error: Error) => void; } /** * 설정 변경 콜백 */ export type ConfigChangeCallback = ( newConfig: ManduConfig, event: ConfigChangeEvent ) => void; /** * 설정 감시 결과 */ export interface ConfigWatcher { /** 감시 중지 */ stop: () => void; /** 현재 설정 */ getConfig: () => ManduConfig; /** 수동 리로드 */ reload: () => Promise; } /** * 설정 파일 감시 및 핫 리로드 * * @example * ```ts * const watcher = await watchConfig( * "/path/to/project", * (newConfig, event) => { * console.log("Config changed:", event.path); * applyConfig(newConfig); * }, * { debounceMs: 200 } * ); * * // 나중에 정리 * watcher.stop(); * ``` */ export async function watchConfig( rootDir: string, onReload: ConfigChangeCallback, options: WatchConfigOptions = {} ): Promise { const { debounceMs = 100, immediate = false, onError } = options; // 현재 설정 로드 let currentConfig = await loadManduConfig(rootDir); let debounceTimer: ReturnType | null = null; let watchers: FSWatcher[] = []; let isWatching = true; // 감시 대상 파일들 const configFiles = [ "mandu.config.ts", "mandu.config.js", "mandu.config.json", ".mandu/guard.json", ]; /** * 설정 리로드 수행 */ const doReload = async (changedPath: string): Promise => { if (!isWatching) return; try { const previous = currentConfig; const newConfig = await loadManduConfig(rootDir); // 설정이 동일하면 무시 if (JSON.stringify(previous) === JSON.stringify(newConfig)) { return; } // 이전에 설정이 있었는데 새 설정이 비어있으면 (파싱 에러 등) // 기존 설정 유지하고 에러 핸들러 호출 const previousHasContent = Object.keys(previous).length > 0; const newIsEmpty = Object.keys(newConfig).length === 0; if (previousHasContent && newIsEmpty) { if (onError) { onError(new Error(`Failed to reload config from ${changedPath}`)); } return; // 기존 설정 유지 } currentConfig = newConfig; const event: ConfigChangeEvent = { previous, current: newConfig, path: changedPath, timestamp: new Date(), }; onReload(newConfig, event); } catch (error) { if (onError && error instanceof Error) { onError(error); } // 에러 시 기존 설정 유지 } }; /** * 디바운스된 리로드 */ const scheduleReload = (changedPath: string): void => { if (debounceTimer) { clearTimeout(debounceTimer); } debounceTimer = setTimeout(() => { debounceTimer = null; void doReload(changedPath); }, debounceMs); }; // 각 설정 파일 감시 시작 for (const fileName of configFiles) { const filePath = `${rootDir}/${fileName}`; try { const watcher = watch(filePath, (eventType) => { if (eventType === "change" && isWatching) { scheduleReload(filePath); } }); watcher.on("error", () => { // 파일이 없거나 접근 불가 - 무시 }); watchers.push(watcher); } catch { // 파일이 없으면 감시 생략 } } // 초기 콜백 호출 if (immediate) { const event: ConfigChangeEvent = { previous: {}, current: currentConfig, path: rootDir, timestamp: new Date(), }; onReload(currentConfig, event); } return { stop: () => { isWatching = false; if (debounceTimer) { clearTimeout(debounceTimer); debounceTimer = null; } for (const watcher of watchers) { watcher.close(); } watchers = []; }, getConfig: () => currentConfig, reload: async () => { const previous = currentConfig; currentConfig = await loadManduConfig(rootDir); if (JSON.stringify(previous) !== JSON.stringify(currentConfig)) { const event: ConfigChangeEvent = { previous, current: currentConfig, path: rootDir, timestamp: new Date(), }; onReload(currentConfig, event); } return currentConfig; }, }; } /** * 간단한 단일 파일 감시 * * @example * ```ts * const stop = watchConfigFile( * "/path/to/mandu.config.ts", * async (path) => { * const config = await loadManduConfig(dirname(path)); * applyConfig(config); * } * ); * * // 정리 * stop(); * ``` */ export function watchConfigFile( filePath: string, onChange: (path: string) => void, debounceMs = 100 ): () => void { let timer: ReturnType | null = null; let watcher: FSWatcher | null = null; try { watcher = watch(filePath, (eventType) => { if (eventType === "change") { if (timer) clearTimeout(timer); timer = setTimeout(() => { timer = null; onChange(filePath); }, debounceMs); } }); } catch { // 파일 없음 - 빈 함수 반환 return () => {}; } return () => { if (timer) { clearTimeout(timer); timer = null; } if (watcher) { watcher.close(); watcher = null; } }; } /** * 설정 변경 감지 헬퍼 * * 특정 설정 섹션의 변경 여부 확인 */ export function hasConfigChanged( previous: ManduConfig, current: ManduConfig, section?: keyof ManduConfig ): boolean { if (section) { return JSON.stringify(previous[section]) !== JSON.stringify(current[section]); } return JSON.stringify(previous) !== JSON.stringify(current); } /** * 변경된 설정 섹션 목록 */ export function getChangedSections( previous: ManduConfig, current: ManduConfig ): (keyof ManduConfig)[] { const sections: (keyof ManduConfig)[] = [ "server", "guard", "build", "dev", "fsRoutes", "seo", ]; return sections.filter( (section) => JSON.stringify(previous[section]) !== JSON.stringify(current[section]) ); }