Files
tswatch/ts/tswatch.classes.watcher.ts

223 lines
6.0 KiB
TypeScript

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<any>;
/** 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();
}
}
}