@travetto/watch
Version:
Support for making files watchable during runtime
248 lines (207 loc) • 7.09 kB
text/typescript
import { lstatSync, readdir, statSync, } from 'fs';
import * as ts from 'typescript';
import { ScanEntry, ScanFs, PathUtil, ScanHandler } from '@travetto/boot';
import { WatchEmitter } from './emitter';
/**
* Watch Options
*/
export interface WatcherOptions {
cwd?: string; // The relative cwd
maxListeners?: number; // Max number of file listeners
interval?: number; // Polling interval for watching
ignoreInitial?: boolean; // Ignore initial load
exclude?: ScanHandler;
}
/**
* Standard watcher built on node fs libs
*/
export class Watcher extends WatchEmitter {
#watched = new Map<string, ScanEntry>();
#directories = new Map<string, { close: () => void }>();
#files = new Map<string, { close: () => void }>();
#folder: string;
#options: WatcherOptions;
/**
* Create a new watcher, priming the root direction
* as the starting point
* @param opts
*/
constructor(folder: string, options: WatcherOptions = {}) {
super(options.maxListeners);
this.#options = { interval: 100, ...options };
this.#folder = PathUtil.resolveUnix(this.#options.cwd ?? PathUtil.cwd, folder);
this.suppress = !!this.#options.ignoreInitial;
this.#watch(
{ file: this.#folder, module: this.#folder, stats: statSync(this.#folder) },
...ScanFs.scanDirSync(this.#options.exclude ?? { testFile: (): boolean => true, testDir: (): boolean => true }, this.#folder)
);
// Allow initial suppression for 1s
setTimeout(() => this.suppress = false, 1000);
}
/**
* Handle when a directory if the target of a change event
*/
#processDirectoryChange(dir: ScanEntry): void {
dir.children = dir.children ?? [];
readdir(dir.file, (err, current) => {
if (err && !this.#handleError(err)) {
current = [];
}
// Convert to full paths
current = current.filter(x => !x.startsWith('.')).map(x => PathUtil.joinUnix(dir.file, x));
// Get watched files for this dir
const previous = (dir.children ?? []).slice(0);
// If file was deleted
for (const child of previous) {
if (current.indexOf(child.file) < 0) {
// Remove from watching
this.#unwatch(child.file);
dir.children!.splice(dir.children!.indexOf(child), 1);
this.emit(ScanFs.isNotDir(child) ? 'removed' : 'removedDir', child);
}
}
const prevSet = new Set(previous.map(x => x.file));
// If file was added
for (const next of current) {
const nextStats = lstatSync(next);
if (!prevSet.has(next)) {
const sub = { file: next, module: next, stats: nextStats };
this.#watch(sub);
dir.children!.push(sub);
this.emit(ScanFs.isNotDir(sub) ? 'added' : 'addedDir', sub);
}
}
});
}
/**
* Start watching a directory using fs.watch
*/
#watchDirectory(entry: ScanEntry): void {
if (ScanFs.isNotDir(entry)) {
throw new Error(`Not a directory: ${entry.file}`);
}
try {
console.debug('Watching Directory', { directory: entry.file });
// const watcher = fs.watch(PathUtil.resolveUnix(entry.file), { persistent: false }, () => this.processDirectoryChange(entry);
const watcher = ts.sys.watchDirectory!(PathUtil.resolveUnix(entry.file), () => this.#processDirectoryChange(entry), false);
// watcher.on('error', this.handleError.bind(this));
this.#directories.set(entry.file, watcher);
this.#processDirectoryChange(entry);
} catch (err) {
if (!(err instanceof Error)) {
throw err;
}
this.#handleError(err);
}
}
/**
* Start watching a file. Registers a poller using fs.watch
*/
#watchFile(entry: ScanEntry): void {
if (ScanFs.isDir(entry)) {
throw new Error(`Not a file: ${entry.file}`);
}
const opts = { persistent: false, interval: this.#options.interval };
// const poller = () => {
// // Only emit changed if the file still exists
// // Prevents changed/deleted duplicate events
// try {
// // Get stats on file
// const stats = fs.lstatSync(entry.file);
// entry.stats = stats;
// this.emit('changed', entry);
// } catch (err: any) {
// if (this.handleError(err)) {
// throw err;
// }
// }
// });
const poller = (_: unknown, kind: number): void => {
const stats = lstatSync(entry.file);
entry.stats = stats;
try {
switch (kind) {
case ts.FileWatcherEventKind.Created: this.emit('added', entry); break;
case ts.FileWatcherEventKind.Changed: this.emit('changed', entry); break;
case ts.FileWatcherEventKind.Deleted: this.emit('removed', entry); break;
}
} catch {
console.warn('Error in watching', { file: entry.file });
}
};
this.#files.set(entry.file, ts.sys.watchFile!(entry.file, poller, opts.interval, opts));
// this.#files.set(entry.file, { close: () => fs.unwatchFile(entry.file, poller) });
// fs.watchFile(entry.file, opts, poller);
}
/**
* Stop watching a file
*/
#unwatchFile(entry: ScanEntry): void {
if (this.#files.has(entry.file)) {
console.debug('Unwatching File', { file: entry.file });
this.#files.get(entry.file)!.close();
this.#files.delete(entry.file);
}
}
/**
* Stop watching a directory
*/
#unwatchDirectory(entry: ScanEntry): void {
if (this.#directories.has(entry.file)) {
console.debug('Unwatching Directory', { directory: entry.file });
for (const child of (entry.children ?? [])) {
this.#unwatch(child.file);
}
this.#directories.get(entry.file)!.close();
this.#directories.delete(entry.file);
}
}
/**
* Watch an entry, could be a file or a folder
*/
#watch(...entries: ScanEntry[]): void {
for (const entry of entries.filter(x => !this.#watched.has(x.file))) {
this.#watched.set(entry.file, entry);
if (ScanFs.isDir(entry)) { // Watch Directory
this.#watchDirectory(entry);
} else { // Watch File
this.#watchFile(entry);
}
}
}
/**
* Unwatch a path
*/
#unwatch(...files: string[]): void {
for (const file of files.filter(x => this.#watched.has(x))) {
const entry = this.#watched.get(file)!;
if (!entry) {
return;
}
this.#watched.delete(file);
if (ScanFs.isDir(entry)) {
this.#unwatchDirectory(entry);
} else {
this.#unwatchFile(entry);
}
}
}
/**
* Handle watch error
*/
#handleError(err: Error & { code?: string }): boolean {
switch (err.code) {
case 'EMFILE': this.emit('error', new Error('EMFILE: Too many opened files.')); break;
case 'ENOENT': return false;
default: this.emit('error', err);
}
return true;
}
/**
* Close the watcher, releasing all the file system pollers
*/
close(): void {
this.#unwatch(...this.#watched.keys());
setImmediate(() => this.removeAllListeners());
}
}