BREAKING CHANGE(tswatch): refactor tswatch to a config-driven design (load config from npmextra.json) and add interactive init wizard; change TsWatch public API and enhance Watcher behavior
This commit is contained in:
@@ -1,11 +1,24 @@
|
||||
import * as plugins from './tswatch.plugins.js';
|
||||
import * as interfaces from './interfaces/index.js';
|
||||
import { logger } from './tswatch.logging.js';
|
||||
|
||||
export interface IWatcherConstructorOptions {
|
||||
filePathToWatch: string;
|
||||
/** 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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -22,53 +35,148 @@ export class Watcher {
|
||||
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 = optionsArg;
|
||||
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() {
|
||||
logger.log('info', `trying to start watcher for ${this.options.filePathToWatch}`);
|
||||
const name = this.getName();
|
||||
logger.log('info', `[${name}] starting watcher`);
|
||||
await this.setupCleanup();
|
||||
console.log(`Looking at ${this.options.filePathToWatch} for changes`);
|
||||
// Convert directory path to glob pattern for smartwatch
|
||||
const watchPath = this.options.filePathToWatch.endsWith('/')
|
||||
? `${this.options.filePathToWatch}**/*`
|
||||
: `${this.options.filePathToWatch}/**/*`;
|
||||
this.smartwatchInstance.add([watchPath]);
|
||||
|
||||
// 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.updateCurrentExecution();
|
||||
this.handleChange();
|
||||
});
|
||||
await this.updateCurrentExecution();
|
||||
logger.log('info', `watcher started for ${this.options.filePathToWatch}`);
|
||||
|
||||
// Run on start if configured
|
||||
if (this.options.runOnStart) {
|
||||
await this.executeCommand();
|
||||
}
|
||||
|
||||
logger.log('info', `[${name}] watcher started`);
|
||||
}
|
||||
|
||||
/**
|
||||
* updates the current execution
|
||||
* Handle file change with debouncing
|
||||
*/
|
||||
private async updateCurrentExecution() {
|
||||
if (this.options.commandToExecute) {
|
||||
if (this.currentExecution) {
|
||||
logger.log('ok', `reexecuting ${this.options.commandToExecute}`);
|
||||
this.currentExecution.kill();
|
||||
} else {
|
||||
logger.log('ok', `executing ${this.options.commandToExecute} for the first time`);
|
||||
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,
|
||||
);
|
||||
} else {
|
||||
console.log('no executionCommand set');
|
||||
|
||||
// Track when execution completes
|
||||
this.currentExecution.childProcess.on('exit', () => {
|
||||
this.isExecuting = false;
|
||||
});
|
||||
}
|
||||
|
||||
if (this.options.functionToCall) {
|
||||
this.options.functionToCall();
|
||||
} else {
|
||||
console.log('no functionToCall set.');
|
||||
this.isExecuting = true;
|
||||
try {
|
||||
await this.options.functionToCall();
|
||||
} finally {
|
||||
this.isExecuting = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,6 +211,9 @@ export class Watcher {
|
||||
* 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();
|
||||
|
||||
Reference in New Issue
Block a user