import * as plugins from './tswatch.plugins.js'; import * as interfaces from './interfaces/index.js'; import { logger } from './tswatch.logging.js'; export interface IWatcherConstructorOptions { /** Name for this watcher (used in logging) */ name?: string; /** Path(s) to watch - can be a single path or array */ filePathToWatch: string | string[]; /** Shell command to execute on changes */ commandToExecute?: string; /** Function to call on changes */ functionToCall?: () => Promise; /** Timeout for the watcher */ timeout?: number; /** If true, kill previous process before restarting (default: true) */ restart?: boolean; /** Debounce delay in ms (default: 300) */ debounce?: number; /** If true, run the command immediately on start (default: true) */ runOnStart?: boolean; } /** * A watcher keeps track of one child execution */ export class Watcher { /** * used to execute shell commands */ private smartshellInstance = new plugins.smartshell.Smartshell({ executor: 'bash', }); private currentExecution: plugins.smartshell.IExecResultStreaming; private smartwatchInstance = new plugins.smartwatch.Smartwatch([]); private options: IWatcherConstructorOptions; private debounceTimer: NodeJS.Timeout | null = null; private isExecuting = false; private pendingExecution = false; constructor(optionsArg: IWatcherConstructorOptions) { this.options = { restart: true, debounce: 300, runOnStart: true, ...optionsArg, }; } /** * Create a Watcher from config */ public static fromConfig(config: interfaces.IWatcherConfig): Watcher { const watchPaths = Array.isArray(config.watch) ? config.watch : [config.watch]; return new Watcher({ name: config.name, filePathToWatch: watchPaths, commandToExecute: config.command, restart: config.restart ?? true, debounce: config.debounce ?? 300, runOnStart: config.runOnStart ?? true, }); } /** * Get the watcher name for logging */ private getName(): string { return this.options.name || 'unnamed'; } /** * start the file */ public async start() { const name = this.getName(); logger.log('info', `[${name}] starting watcher`); await this.setupCleanup(); // Convert paths to glob patterns const paths = Array.isArray(this.options.filePathToWatch) ? this.options.filePathToWatch : [this.options.filePathToWatch]; const watchPatterns = paths.map((p) => { // Convert directory path to glob pattern for smartwatch if (p.endsWith('/')) { return `${p}**/*`; } // If it's already a glob pattern, use as-is if (p.includes('*')) { return p; } // Otherwise assume it's a directory return `${p}/**/*`; }); logger.log('info', `[${name}] watching patterns: ${watchPatterns.join(', ')}`); this.smartwatchInstance.add(watchPatterns); await this.smartwatchInstance.start(); const changeObservable = await this.smartwatchInstance.getObservableFor('change'); changeObservable.subscribe(() => { this.handleChange(); }); // Run on start if configured if (this.options.runOnStart) { await this.executeCommand(); } logger.log('info', `[${name}] watcher started`); } /** * Handle file change with debouncing */ private handleChange() { const name = this.getName(); // Clear existing debounce timer if (this.debounceTimer) { clearTimeout(this.debounceTimer); } // Set new debounce timer this.debounceTimer = setTimeout(async () => { this.debounceTimer = null; // If currently executing and not in restart mode, mark pending if (this.isExecuting && !this.options.restart) { logger.log('info', `[${name}] change detected, queuing execution`); this.pendingExecution = true; return; } await this.executeCommand(); // If there was a pending execution, run it if (this.pendingExecution) { this.pendingExecution = false; await this.executeCommand(); } }, this.options.debounce); } /** * Execute the command or function */ private async executeCommand() { const name = this.getName(); if (this.options.commandToExecute) { if (this.currentExecution && this.options.restart) { logger.log('ok', `[${name}] restarting: ${this.options.commandToExecute}`); this.currentExecution.kill(); } else if (!this.currentExecution) { logger.log('ok', `[${name}] executing: ${this.options.commandToExecute}`); } this.isExecuting = true; this.currentExecution = await this.smartshellInstance.execStreaming( this.options.commandToExecute, ); // Track when execution completes this.currentExecution.childProcess.on('exit', () => { this.isExecuting = false; }); } if (this.options.functionToCall) { this.isExecuting = true; try { await this.options.functionToCall(); } finally { this.isExecuting = false; } } } /** * this method sets up a clean exit strategy */ private async setupCleanup() { process.on('exit', () => { console.log(''); console.log('now exiting!'); this.stop(); process.exit(0); }); process.on('SIGINT', () => { console.log(''); console.log('ok! got SIGINT We are exiting! Just cleaning up to exit neatly :)'); this.stop(); process.exit(0); }); // handle timeout if (this.options.timeout) { plugins.smartdelay.delayFor(this.options.timeout).then(() => { console.log(`timed out afer ${this.options.timeout} milliseconds! exiting!`); this.stop(); process.exit(0); }); } } /** * stops the watcher */ public async stop() { if (this.debounceTimer) { clearTimeout(this.debounceTimer); } await this.smartwatchInstance.stop(); if (this.currentExecution && !this.currentExecution.childProcess.killed) { this.currentExecution.kill(); } } }