import * as plugins from './smartwatch.plugins.js'; import { Stringmap } from '@push.rocks/lik'; import { createWatcher, type IWatcher, type IWatchEvent, type TWatchEventType } from './watchers/index.js'; export type TSmartwatchStatus = 'idle' | 'starting' | 'watching'; export type TFsEvent = | 'add' | 'addDir' | 'change' | 'error' | 'unlink' | 'unlinkDir' | 'ready' | 'raw'; /** * Smartwatch allows easy watching of files * Uses native file watching APIs (Node.js fs.watch, Deno.watchFs) for cross-runtime support */ export class Smartwatch { public watchStringmap = new Stringmap(); public status: TSmartwatchStatus = 'idle'; private watcher: IWatcher | null = null; private globPatterns: string[] = []; private globMatchers: Map boolean> = new Map(); private watchingDeferred = plugins.smartpromise.defer(); // Event subjects for each event type private eventSubjects: Map> = new Map(); /** * constructor of class Smartwatch */ constructor(watchArrayArg: string[]) { this.watchStringmap.addStringArray(watchArrayArg); // Initialize subjects for each event type const eventTypes: TFsEvent[] = ['add', 'addDir', 'change', 'error', 'unlink', 'unlinkDir', 'ready', 'raw']; for (const eventType of eventTypes) { this.eventSubjects.set(eventType, new plugins.smartrx.rxjs.Subject()); } } private getGlobBase(globPattern: string) { // Characters that mark the beginning of a glob pattern const globChars = ['*', '?', '[', ']', '{', '}']; // Find the index of the first glob character const firstGlobCharIndex = globPattern.split('').findIndex((char) => globChars.includes(char)); // If no glob characters are found, return the entire string if (firstGlobCharIndex === -1) { return globPattern; } // Extract the substring up to the first glob character const basePathPortion = globPattern.substring(0, firstGlobCharIndex); // Find the last slash before the glob pattern starts const lastSlashIndex = basePathPortion.lastIndexOf('/'); // If there is no slash, return the basePathPortion as is if (lastSlashIndex === -1) { return basePathPortion; } // Return the base path up to and including the last slash return basePathPortion.substring(0, lastSlashIndex + 1); } /** * adds files to the list of watched files */ public add(pathArrayArg: string[]) { this.watchStringmap.addStringArray(pathArrayArg); } /** * removes files from the list of watched files */ public remove(pathArg: string) { this.watchStringmap.removeString(pathArg); } /** * gets an observable for a certain event */ public getObservableFor( fsEvent: TFsEvent ): Promise> { const done = plugins.smartpromise.defer>(); this.watchingDeferred.promise.then(() => { const subject = this.eventSubjects.get(fsEvent); if (subject) { done.resolve(subject.asObservable()); } else { done.reject(new Error(`Unknown event type: ${fsEvent}`)); } }); return done.promise; } /** * starts the watcher * @returns Promise */ public async start(): Promise { this.status = 'starting'; // Store original glob patterns and create matchers this.globPatterns = this.watchStringmap.getStringArray(); const basePaths = new Set(); this.globPatterns.forEach((pattern) => { const basePath = this.getGlobBase(pattern); basePaths.add(basePath); // Create a picomatch matcher for each glob pattern const matcher = plugins.picomatch(pattern, { dot: true, basename: false }); this.globMatchers.set(pattern, matcher); }); // Convert Set to Array for the watcher const watchPaths = Array.from(basePaths); console.log('Base paths to watch:', watchPaths); // Create the platform-appropriate watcher this.watcher = await createWatcher({ basePaths: watchPaths, depth: 4, followSymlinks: false, stabilityThreshold: 300, pollInterval: 100 }); // Subscribe to watcher events and dispatch to appropriate subjects this.watcher.events$.subscribe((event: IWatchEvent) => { this.handleWatchEvent(event); }); // Start the watcher await this.watcher.start(); this.status = 'watching'; this.watchingDeferred.resolve(); } /** * Handle events from the native watcher */ private handleWatchEvent(event: IWatchEvent): void { // Handle ready event if (event.type === 'ready') { const subject = this.eventSubjects.get('ready'); if (subject) { subject.next(['', undefined]); } return; } // Handle error event if (event.type === 'error') { const subject = this.eventSubjects.get('error'); if (subject) { subject.next([event.error?.message || 'Unknown error', undefined]); } return; } // Filter file/directory events by glob patterns if (!this.shouldWatchPath(event.path)) { return; } const subject = this.eventSubjects.get(event.type as TFsEvent); if (subject) { subject.next([event.path, event.stats]); } } /** * stop the watcher process if watching */ public async stop() { const closeWatcher = async () => { if (this.watcher) { await this.watcher.stop(); this.watcher = null; } }; if (this.status === 'watching') { console.log('closing while watching'); await closeWatcher(); } else if (this.status === 'starting') { await this.watchingDeferred.promise; await closeWatcher(); } this.status = 'idle'; } /** * Checks if a path should be watched based on glob patterns */ private shouldWatchPath(filePath: string): boolean { // Normalize the path - remove leading ./ if present let normalizedPath = filePath.replace(/\\/g, '/'); if (normalizedPath.startsWith('./')) { normalizedPath = normalizedPath.substring(2); } // Check if the path matches any of our glob patterns for (const [pattern, matcher] of this.globMatchers) { // Also normalize the pattern for comparison let normalizedPattern = pattern; if (normalizedPattern.startsWith('./')) { normalizedPattern = normalizedPattern.substring(2); } // Try matching with both the original pattern and normalized if (matcher(normalizedPath) || matcher(filePath)) { return true; } // Also try matching without the leading path const withoutLeading = 'test/' + normalizedPath.split('test/').slice(1).join('test/'); if (matcher(withoutLeading)) { return true; } } return false; } }