1
Fork 0
cheap-watch/src/CheapWatch.ts
2021-09-06 18:03:09 -04:00

180 lines
5.1 KiB
TypeScript

import { EventEmitter } from 'events';
import * as fs from 'fs';
import { promisify } from 'util';
const readdir = promisify(fs.readdir);
const stat = promisify(fs.stat);
export default class CheapWatch extends EventEmitter {
// root directory
dir: string;
// function to limit watching to certain directories/files
filter?: Filter;
// whether to actually watch for changes, or just report all matching files and their stats
watch = true;
// number of milliseconds to use to debounce events from FSWatcher
debounce = 10;
// paths of all files/dirs -> stats
paths = new Map<string, fs.Stats>();
// paths of all directories -> FSWatcher instances
private _watchers = new Map<string, fs.FSWatcher>();
// paths of files/dirs with pending debounced events -> setTimeout timer ids
private _timeouts = new Map<string, NodeJS.Timer>();
// queue of paths of pending FSWatcher events to handle
private _queue: string[] = [];
// current status of instance
private _status = Status.Created;
constructor(data: object /* = { dir, filter, watch, debounce } */) {
super();
Object.assign(this, data);
if (typeof this.dir !== 'string') {
throw new TypeError('dir must be a string');
}
if (this.filter && typeof this.filter !== 'function') {
throw new TypeError('filter must be a function');
}
if (typeof this.watch !== 'boolean') {
throw new TypeError('watch must be a boolean');
}
if (typeof this.debounce !== 'number') {
throw new TypeError('debounce must be a number');
}
}
// recurse directory, get stats, set up FSWatcher instances
async init(): Promise<void> {
if (this._status !== Status.Created) {
throw new Error('cannot call init() twice');
}
this._status = Status.Initing;
await this._recurse(this.dir);
this._status = Status.Ready;
}
// close all FSWatchers
close(): void {
if (this._status === Status.Created || this._status === Status.Initing) {
throw new Error('cannot call close() before init() finishes');
}
if (this._status === Status.Closed) {
throw new Error('cannot call close() twice');
}
this._status = Status.Closed;
for (const watcher of this._watchers.values()) {
watcher.close();
}
}
// recurse a given directory
private async _recurse(full: string): Promise<void> {
const path = full.slice(this.dir.length + 1);
const stats = await stat(full);
if (path) {
if (this.filter && !(await this.filter({ path, stats }))) {
return;
}
this.paths.set(path, stats);
}
if (stats.isDirectory()) {
if (this.watch) {
this._watchers.set(
path,
fs.watch(full, this._handle.bind(this, full)).on('error', () => {}),
);
}
await Promise.all(
(await readdir(full)).map(sub => this._recurse(full + '/' + sub)),
);
}
}
// handle FSWatcher event for given directory
private _handle(dir: string, event: string, file: string): void {
this._debounce(dir);
this._debounce(dir + '/' + file);
}
// debounce and enqueue event for given path
private _debounce(path: string): void {
if (this._timeouts.has(path)) {
clearTimeout(this._timeouts.get(path));
}
this._timeouts.set(
path,
setTimeout(() => {
this._timeouts.delete(path);
this._enqueue(path);
}, this.debounce),
);
}
// add an FSWatcher event to the queue, and handle queued events
private async _enqueue(full: string): Promise<void> {
this._queue.push(full);
if (this._status !== Status.Ready) {
return;
}
this._status = Status.Processing;
while (this._queue.length) {
const full = this._queue.shift();
const path = full.slice(this.dir.length + 1);
const stats = await stat(full).catch(() => {});
if (stats) {
if (this.filter && !(await this.filter({ path, stats }))) {
continue;
}
const isNew = !this.paths.has(path);
this.paths.set(path, stats);
if (path) {
this.emit('+', { path, stats, isNew });
}
if (stats.isDirectory() && !this._watchers.has(path)) {
// note the new directory
// start watching it, and report any files in it
await this._recurse(full);
for (const [new_path, stats] of this.paths.entries()) {
if (new_path.startsWith(path + '/')) {
this.emit('+', { path: new_path, stats, isNew: true });
}
}
}
} else if (this.paths.has(path)) {
// note the deleted file/dir
const stats = this.paths.get(path);
this.paths.delete(path);
this.emit('-', { path, stats });
if (this._watchers.has(path)) {
// stop watching it, and report any files/dirs that were in it
for (const old_path of this._watchers.keys()) {
if (old_path === path || old_path.startsWith(path + '/')) {
this._watchers.get(old_path).close();
this._watchers.delete(old_path);
}
}
for (const old_path of this.paths.keys()) {
if (old_path.startsWith(path + '/')) {
const stats = this.paths.get(old_path);
this.paths.delete(old_path);
this.emit('-', { path: old_path, stats });
}
}
}
}
}
this._status = Status.Ready;
}
}
interface Filter {
(file: { path: string; stats: fs.Stats }): Promise<boolean>;
}
const enum Status {
Created,
Initing,
Ready,
Processing,
Closed,
}