/** * File Watcher Service * * Provides robust cross-platform file watching using Chokidar library. * Handles file watching with debouncing, error recovery, and graceful fallbacks. */ import * as chokidar from "chokidar"; import { EventEmitter } from "events"; import type { Logger } from "../types/index.js"; export interface FileWatchEvent { type: "change" | "create" | "delete" | "rename"; path: string; timestamp: number; size?: number; } export interface FileWatcherConfig { stabilityThreshold: number; // Chokidar awaitWriteFinish delay (ms) pollInterval: number; // Chokidar polling interval (ms) maxRetries: number; // Default: 3 fallbackPolling: boolean; // Default: false ignoreTempFiles: boolean; // Default: true depth: number; // default: 5 ignored: string[]; // default: ['**/node_modules/**', '**/.git/**', '**/*.swp', '**/*~'] } export interface FileWatcherStatus { isActive: boolean; path: string; method: "native" | "polling" | "failed"; errorCount: number; lastError?: string; lastEvent?: FileWatchEvent; } interface FileWatcherEntry { path: string; watcher: chokidar.FSWatcher | null; isActive: boolean; lastEvent: number; errorCount: number; lastError?: string; callbacks: Set<(event: FileWatchEvent) => void>; config: FileWatcherConfig; } export class FileWatcherService extends EventEmitter { private watchers: Map = new Map(); private globalWatcher: chokidar.FSWatcher | null = null; private defaultConfig: FileWatcherConfig; private logger?: Logger; constructor(logger?: Logger, config?: Partial) { super(); this.logger = logger; this.defaultConfig = { stabilityThreshold: 300, pollInterval: 100, maxRetries: 3, fallbackPolling: false, ignoreTempFiles: true, depth: 5, ignored: ["**/node_modules/**", "**/.git/**", "**/*.swp", "**/*~"], ...config, }; } /** * Start watching a file * Maps to FR-010: Handle file deletion, creation, and modification */ async watchFile( path: string, callback: (event: FileWatchEvent) => void, ): Promise { try { if (this.watchers.has(path)) { // Add callback to existing watcher const entry = this.watchers.get(path)!; entry.callbacks.add(callback); return; } // Create new watcher entry const entry: FileWatcherEntry = { path, watcher: null, isActive: false, lastEvent: Date.now(), errorCount: 0, lastError: undefined, callbacks: new Set([callback]), config: { ...this.defaultConfig }, }; this.watchers.set(path, entry); await this.initializeWatcher(entry); } catch (error) { this.logger?.error( `FileWatcher: Failed to watch file ${path}: ${(error as Error).message}`, ); throw error; } } /** * Stop watching a file * Resource cleanup */ async unwatchFile(path: string): Promise { const entry = this.watchers.get(path); if (!entry) return; try { if (entry.watcher) { entry.watcher.unwatch(path); } this.watchers.delete(path); } catch (error) { this.logger?.warn( `FileWatcher: Error unwatching file ${path}: ${(error as Error).message}`, ); } } /** * Get watcher status * Maps to FR-012: Handle watcher initialization failures */ getWatcherStatus(path: string): FileWatcherStatus | null { const entry = this.watchers.get(path); if (!entry) return null; return { isActive: entry.isActive, path: entry.path, method: entry.errorCount > 0 ? "failed" : entry.config.fallbackPolling ? "polling" : "native", errorCount: entry.errorCount, lastError: entry.lastError, lastEvent: entry.lastEvent > 0 ? { type: "change", path: entry.path, timestamp: entry.lastEvent, } : undefined, }; } /** * Get all watcher statuses * For monitoring and debugging */ getAllWatcherStatuses(): FileWatcherStatus[] { return Array.from(this.watchers.keys()) .map((path) => this.getWatcherStatus(path)) .filter((status): status is FileWatcherStatus => status !== null); } /** * Cleanup all watchers */ async cleanup(): Promise { const paths = Array.from(this.watchers.keys()); for (const path of paths) { await this.unwatchFile(path); } if (this.globalWatcher) { const watcher = this.globalWatcher; this.globalWatcher = null; await watcher.close(); } } private async initializeWatcher(entry: FileWatcherEntry): Promise { try { // Initialize global watcher if needed if (!this.globalWatcher) { this.globalWatcher = chokidar.watch([], { persistent: true, ignoreInitial: true, depth: entry.config.depth, ignored: entry.config.ignored, awaitWriteFinish: { stabilityThreshold: entry.config.stabilityThreshold, pollInterval: entry.config.pollInterval, }, usePolling: entry.config.fallbackPolling, interval: entry.config.pollInterval, }); // Unref the global watcher to allow natural exit if it's the only thing left // (chokidar watchers keep the process alive by default) // Note: some platforms might need additional handling const watcher = this.globalWatcher as unknown as { unref?: () => void }; if (watcher.unref) { watcher.unref(); } this.setupGlobalWatcherEvents(); } // Add path to global watcher this.globalWatcher.add(entry.path); entry.watcher = this.globalWatcher; entry.isActive = true; entry.errorCount = 0; } catch (error) { entry.errorCount++; entry.isActive = false; entry.lastError = (error as Error).message; this.logger?.error( `FileWatcher: Failed to initialize watcher for ${entry.path}: ${(error as Error).message}`, ); // Try fallback polling if not already using it if ( !entry.config.fallbackPolling && entry.errorCount < entry.config.maxRetries ) { entry.config.fallbackPolling = true; await this.initializeWatcher(entry); } else { throw error; } } } private setupGlobalWatcherEvents(): void { if (!this.globalWatcher) return; this.globalWatcher.on( "change", (filePath: string, stats?: { size?: number }) => { this.handleFileEvent("change", filePath, stats); }, ); this.globalWatcher.on( "add", (filePath: string, stats?: { size?: number }) => { this.handleFileEvent("create", filePath, stats); }, ); this.globalWatcher.on("unlink", (filePath: string) => { this.handleFileEvent("delete", filePath); }); this.globalWatcher.on("addDir", (dirPath: string) => { this.handleFileEvent("create", dirPath); }); this.globalWatcher.on("unlinkDir", (dirPath: string) => { this.handleFileEvent("delete", dirPath); }); this.globalWatcher.on("error", (err: unknown) => { const error = err instanceof Error ? err : new Error(String(err)); const isEMFILE = (error as NodeJS.ErrnoException).code === "EMFILE"; if (isEMFILE) { this.logger?.warn( "FileWatcher: Too many open files (EMFILE). " + "Consider increasing the limit: `ulimit -n 10240` (macOS) or `ulimit -n 65536` (Linux).", ); } else { this.logger?.error(`FileWatcher: File watcher error: ${error.message}`); } this.emit("watcherError", error); }); } private handleFileEvent( type: FileWatchEvent["type"], filePath: string, stats?: { size?: number }, ): void { const event: FileWatchEvent = { type, path: filePath, timestamp: Date.now(), size: stats?.size, }; // Notify all watchers that match the path or are parents of the path for (const [watchedPath, entry] of this.watchers.entries()) { const normalizedFilePath = filePath.replace(/\\/g, "/"); const normalizedWatchedPath = watchedPath.replace(/\\/g, "/"); if ( normalizedFilePath === normalizedWatchedPath || normalizedFilePath.startsWith(normalizedWatchedPath + "/") ) { entry.lastEvent = event.timestamp; // Notify all callbacks for this watcher for (const callback of entry.callbacks) { try { callback(event); } catch (error) { this.logger?.error( `FileWatcher: Error in file watch callback for ${watchedPath} (event on ${filePath}): ${(error as Error).message}`, ); } } } } } }