239 lines
5.7 KiB
TypeScript
239 lines
5.7 KiB
TypeScript
/**
|
|
* File Watcher Module
|
|
*
|
|
* Watches source directories for file changes and emits debounced events.
|
|
* Uses chokidar for cross-platform file system watching.
|
|
*/
|
|
|
|
import chokidar from 'chokidar';
|
|
import type { FSWatcher } from 'chokidar';
|
|
import type { FileEventType, WatchConfig, WatcherCallbacks } from './types.js';
|
|
|
|
// ============================================
|
|
// Constants
|
|
// ============================================
|
|
|
|
const DEFAULT_DEBOUNCE_MS = 500;
|
|
const DEFAULT_IGNORE_PATHS = [
|
|
'node_modules',
|
|
'.git',
|
|
'dist',
|
|
'build',
|
|
'.next',
|
|
'coverage',
|
|
];
|
|
|
|
// ============================================
|
|
// Watcher Interface
|
|
// ============================================
|
|
|
|
export interface Watcher {
|
|
/** Start watching the configured directories */
|
|
start(): void;
|
|
/** Stop watching and clean up resources */
|
|
stop(): void;
|
|
/** Check if the watcher is currently running */
|
|
isRunning(): boolean;
|
|
}
|
|
|
|
// ============================================
|
|
// Internal Types
|
|
// ============================================
|
|
|
|
interface PendingEvent {
|
|
type: FileEventType;
|
|
path: string;
|
|
timestamp: Date;
|
|
}
|
|
|
|
// ============================================
|
|
// Factory Function
|
|
// ============================================
|
|
|
|
/**
|
|
* Creates a file watcher that monitors source directories for changes.
|
|
*
|
|
* @param sourceDirs - Array of directories to watch
|
|
* @param config - Watch configuration options
|
|
* @param callbacks - Event callbacks
|
|
* @returns A Watcher instance
|
|
*
|
|
* @example
|
|
* ```ts
|
|
* const watcher = createWatcher(
|
|
* ['./src', './lib'],
|
|
* { enabled: true, debounceMs: 300, ignorePaths: [] },
|
|
* {
|
|
* onFileChange: (event) => console.log('File changed:', event.path),
|
|
* onError: (error) => console.error('Watch error:', error),
|
|
* }
|
|
* );
|
|
*
|
|
* watcher.start();
|
|
* // ... later
|
|
* watcher.stop();
|
|
* ```
|
|
*/
|
|
export function createWatcher(
|
|
sourceDirs: string[],
|
|
config: WatchConfig,
|
|
callbacks: WatcherCallbacks
|
|
): Watcher {
|
|
let fsWatcher: FSWatcher | null = null;
|
|
let running = false;
|
|
let pendingEvents: Map<string, PendingEvent> = new Map();
|
|
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
const debounceMs = config.debounceMs ?? DEFAULT_DEBOUNCE_MS;
|
|
const ignorePaths = [
|
|
...DEFAULT_IGNORE_PATHS,
|
|
...(config.ignorePaths ?? []),
|
|
];
|
|
|
|
/**
|
|
* Flushes all pending events, calling the onFileChange callback for each
|
|
*/
|
|
function flushEvents(): void {
|
|
if (pendingEvents.size === 0) return;
|
|
|
|
const events = Array.from(pendingEvents.values());
|
|
pendingEvents.clear();
|
|
|
|
for (const event of events) {
|
|
try {
|
|
callbacks.onFileChange?.(event);
|
|
} catch (error) {
|
|
callbacks.onError?.(
|
|
error instanceof Error ? error : new Error(String(error))
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Schedules a debounced flush of events
|
|
*/
|
|
function scheduleFlush(): void {
|
|
if (debounceTimer) {
|
|
clearTimeout(debounceTimer);
|
|
}
|
|
debounceTimer = setTimeout(() => {
|
|
debounceTimer = null;
|
|
flushEvents();
|
|
}, debounceMs);
|
|
}
|
|
|
|
/**
|
|
* Handles a raw file system event from chokidar
|
|
*/
|
|
function handleEvent(type: FileEventType, path: string): void {
|
|
const event: PendingEvent = {
|
|
type,
|
|
path,
|
|
timestamp: new Date(),
|
|
};
|
|
|
|
// Use path as key - later events for same path override earlier ones
|
|
pendingEvents.set(path, event);
|
|
scheduleFlush();
|
|
}
|
|
|
|
/**
|
|
* Handles errors from chokidar
|
|
*/
|
|
function handleError(error: Error): void {
|
|
callbacks.onError?.(error);
|
|
}
|
|
|
|
return {
|
|
start(): void {
|
|
if (running) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// Build ignore patterns from configured paths
|
|
const ignorePatterns = ignorePaths.map((p) => `**/${p}/**`);
|
|
|
|
fsWatcher = chokidar.watch(sourceDirs, {
|
|
ignored: ignorePatterns,
|
|
persistent: true,
|
|
ignoreInitial: true,
|
|
awaitWriteFinish: {
|
|
stabilityThreshold: 100,
|
|
pollInterval: 50,
|
|
},
|
|
});
|
|
|
|
fsWatcher
|
|
.on('add', (path) => handleEvent('add', path))
|
|
.on('change', (path) => handleEvent('change', path))
|
|
.on('unlink', (path) => handleEvent('unlink', path))
|
|
.on('error', handleError);
|
|
|
|
running = true;
|
|
} catch (error) {
|
|
callbacks.onError?.(
|
|
error instanceof Error ? error : new Error(String(error))
|
|
);
|
|
}
|
|
},
|
|
|
|
stop(): void {
|
|
if (!running) {
|
|
return;
|
|
}
|
|
|
|
// Clear any pending debounce timer
|
|
if (debounceTimer) {
|
|
clearTimeout(debounceTimer);
|
|
debounceTimer = null;
|
|
}
|
|
|
|
// Flush any remaining events immediately
|
|
flushEvents();
|
|
|
|
// Close the watcher
|
|
if (fsWatcher) {
|
|
fsWatcher.close().catch((error) => {
|
|
callbacks.onError?.(
|
|
error instanceof Error ? error : new Error(String(error))
|
|
);
|
|
});
|
|
fsWatcher = null;
|
|
}
|
|
|
|
running = false;
|
|
},
|
|
|
|
isRunning(): boolean {
|
|
return running;
|
|
},
|
|
};
|
|
}
|
|
|
|
// ============================================
|
|
// Utility Functions
|
|
// ============================================
|
|
|
|
/**
|
|
* Creates a WatchConfig with sensible defaults
|
|
*/
|
|
export function createDefaultWatchConfig(
|
|
overrides: Partial<WatchConfig> = {}
|
|
): WatchConfig {
|
|
return {
|
|
enabled: overrides.enabled ?? true,
|
|
debounceMs: overrides.debounceMs ?? DEFAULT_DEBOUNCE_MS,
|
|
ignorePaths: overrides.ignorePaths ?? [...DEFAULT_IGNORE_PATHS],
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Merges user-provided ignore paths with defaults
|
|
*/
|
|
export function mergeIgnorePaths(userPaths: string[] = []): string[] {
|
|
const combined = new Set([...DEFAULT_IGNORE_PATHS, ...userPaths]);
|
|
return Array.from(combined);
|
|
}
|