diff --git a/metawatch.js b/metawatch.js index ab6a560..81d2f7f 100644 --- a/metawatch.js +++ b/metawatch.js @@ -6,20 +6,36 @@ const { EventEmitter } = require('node:events'); const WATCH_TIMEOUT = 5000; +const isExcludedFile = (excludeExts, excludeFiles) => (filePath) => { + const { ext, base, name } = path.parse(filePath); + const extIsExclude = excludeExts.has(ext.slice(1)); + if (extIsExclude) return true; + return excludeFiles.has(name) || excludeFiles.has(base); +}; + +const isExcludedDir = (excludePaths) => (dirPath) => { + const dirName = path.basename(dirPath); + return excludePaths.has(dirName) || excludePaths.has(dirPath); +}; + class DirectoryWatcher extends EventEmitter { constructor(options = {}) { super(); - this.watchers = new Map(); + const { dirs = [], files = [], exts = [] } = options.excludes || {}; const { timeout = WATCH_TIMEOUT } = options; this.timeout = timeout; + this.watchers = new Map(); + this.isExcludedFile = isExcludedFile(new Set(exts), new Set(files)); + this.isExcludedDir = isExcludedDir(new Set(dirs)); this.timer = null; this.queue = new Map(); } post(event, filePath) { if (this.timer) clearTimeout(this.timer); - this.queue.set(filePath, event); - if (this.timeout === 0) return void this.sendQueue(); + const events = this.queue.get(filePath); + if (events) events.add(event); + else this.queue.set(filePath, new Set(event)); this.timer = setTimeout(() => { if (this.timer) { clearTimeout(this.timer); @@ -34,21 +50,31 @@ class DirectoryWatcher extends EventEmitter { const queue = [...this.queue.entries()]; this.queue.clear(); this.emit('before', queue); - for (const [filePath, event] of queue) { - this.emit(event, filePath); + for (const [filePath, events] of queue) { + for (const event of events) { + this.emit(event, filePath); + } } this.emit('after', queue); } watchDirectory(targetPath) { - if (this.watchers.get(targetPath)) return; - const watcher = fs.watch(targetPath, (event, fileName) => { + if (this.watchers.has(targetPath)) return; + const watcher = fs.watch(targetPath); + watcher.on('error', () => void this.unwatch(targetPath)); + watcher.on('change', (...args) => { + const fileName = args.pop(); const target = targetPath.endsWith(path.sep + fileName); const filePath = target ? targetPath : path.join(targetPath, fileName); + if (this.isExcludedFile(filePath)) return; + this.post('*', filePath); fs.stat(filePath, (err, stats) => { if (err) { + const keys = [...this.watchers.keys()]; this.unwatch(filePath); - return void this.post('delete', filePath); + this.post('delete', filePath); + const event = keys.includes(filePath) ? 'rmdir' : 'rm'; + return void this.post(event, fileName); } if (stats.isDirectory()) this.watch(filePath); this.post('change', filePath); @@ -58,15 +84,13 @@ class DirectoryWatcher extends EventEmitter { } watch(targetPath) { - const watcher = this.watchers.get(targetPath); - if (watcher) return; + if (this.isExcludedDir(targetPath)) return; fs.readdir(targetPath, { withFileTypes: true }, (err, files) => { if (err) return; for (const file of files) { - if (file.isDirectory()) { - const dirPath = path.join(targetPath, file.name); - this.watch(dirPath); - } + if (!file.isDirectory()) continue; + const dirPath = path.join(targetPath, file.name); + this.watch(dirPath); } this.watchDirectory(targetPath); }); diff --git a/test/unit.js b/test/unit.js index 15c60a8..e20b198 100644 --- a/test/unit.js +++ b/test/unit.js @@ -18,7 +18,7 @@ const cleanup = (dir) => { }); }; -metatests.test('Single file change ', (test) => { +metatests.test('Single file change', (test) => { const targetPath = path.join(dir, 'test/example1'); fs.mkdirSync(targetPath); @@ -91,3 +91,216 @@ metatests.test('Aggregated change', (test) => { } }, WRITE_TIMEOUT); }); + +metatests.test('Exclude extensions', (test) => { + const targetPath = path.join(dir, 'test/example3'); + fs.mkdirSync(targetPath); + + const files = ['test.md', 'test.ts', 'test.ext']; + + const options = { + excludes: { + exts: ['md', 'ts'], + }, + ...OPTIONS, + }; + + const watcher = new metawatch.DirectoryWatcher(options); + watcher.watch(targetPath); + + const timeout = setTimeout(() => { + watcher.unwatch(targetPath); + test.fail(); + }, TEST_TIMEOUT); + + let changeCount = 0; + + watcher.on('change', (fileName) => { + const { ext } = path.parse(fileName); + test.strictSame(ext, '.ext'); + changeCount++; + }); + + watcher.on('after', (changes) => { + test.strictEqual(changeCount, 1); + test.strictEqual(changes.length, 1); + clearTimeout(timeout); + watcher.unwatch(targetPath); + cleanup(targetPath); + test.end(); + }); + + setTimeout(() => { + for (const name of files) { + const filePath = path.join(targetPath, name); + fs.writeFile(filePath, 'example', 'utf8', (err) => { + test.error(err, 'Can not write file'); + }); + } + }, WRITE_TIMEOUT); +}); + +metatests.test('Exclude files', (test) => { + const targetPath = path.join(dir, 'test/example4'); + fs.mkdirSync(targetPath); + + const files = ['test1.ext', 'test2.ext', 'test3.ext']; + + const options = { + excludes: { + files: ['test2', 'test3'], + }, + ...OPTIONS, + }; + + const watcher = new metawatch.DirectoryWatcher(options); + watcher.watch(targetPath); + + const timeout = setTimeout(() => { + watcher.unwatch(targetPath); + test.fail(); + }, TEST_TIMEOUT); + + let changeCount = 0; + + watcher.on('change', (fileName) => { + const { name } = path.parse(fileName); + test.strictSame(name, 'test1'); + changeCount++; + }); + + watcher.on('after', (changes) => { + test.strictEqual(changeCount, 1); + test.strictEqual(changes.length, 1); + clearTimeout(timeout); + watcher.unwatch(targetPath); + cleanup(targetPath); + test.end(); + }); + + setTimeout(() => { + for (const name of files) { + const filePath = path.join(targetPath, name); + fs.writeFile(filePath, 'example', 'utf8', (err) => { + test.error(err, 'Can not write file'); + }); + } + }, WRITE_TIMEOUT); +}); + +metatests.test('Exclude dirs', (test) => { + const targetPath = path.join(dir, 'test/example5'); + fs.mkdirSync(targetPath); + + const options = { + excludes: { + dirs: [targetPath], + }, + ...OPTIONS, + }; + + const watcher = new metawatch.DirectoryWatcher(options); + watcher.watch(targetPath); + + let changeEmitted = false; + + setTimeout(() => { + cleanup(targetPath); + test.strictEqual(changeEmitted, false); + test.end(); + }, TEST_TIMEOUT); + + watcher.on('change', () => { + changeEmitted = true; + }); + + setTimeout(() => { + const filePath = path.join(targetPath, 'test.ext'); + fs.writeFile(filePath, 'example', 'utf8', (err) => { + test.error(err, 'Can not write file'); + }); + }, WRITE_TIMEOUT); +}); + +metatests.test('Delete file (rm)', (test) => { + const targetPath = path.join(dir, 'test/example6'); + fs.mkdirSync(targetPath); + + const filePath = path.join(targetPath, 'test.ext'); + const fd = fs.openSync(filePath, 'a'); + fs.closeSync(fd); + + const watcher = new metawatch.DirectoryWatcher(OPTIONS); + watcher.watch(targetPath); + + const timeout = setTimeout(() => { + watcher.unwatch(targetPath); + test.fail(); + }, TEST_TIMEOUT); + + watcher.on('delete', (fileName) => { + test.strictSame(fileName.endsWith('test.ext'), true); + }); + + watcher.on('rm', (fileName) => { + test.strictSame(fileName.endsWith('test.ext'), true); + }); + + watcher.on('rmdir', () => { + test.fail(); + }); + + watcher.on('after', () => { + clearTimeout(timeout); + watcher.unwatch(targetPath); + cleanup(targetPath); + test.end(); + }); + + setTimeout(() => { + fs.unlink(filePath, (err) => { + test.error(err, 'Can not delete file'); + }); + }, WRITE_TIMEOUT); +}); + +metatests.test('Delete dir (rmdir)', (test) => { + const targetPath = path.join(dir, 'test/example7'); + fs.mkdirSync(targetPath); + + const secondPath = path.join(targetPath, 'test'); + fs.mkdirSync(secondPath); + + const watcher = new metawatch.DirectoryWatcher(OPTIONS); + watcher.watch(targetPath); + + const timeout = setTimeout(() => { + watcher.unwatch(targetPath); + test.fail(); + }, TEST_TIMEOUT); + + watcher.on('delete', (fileName) => { + test.strictSame(fileName.endsWith('test'), true); + }); + + watcher.on('rmdir', (fileName) => { + test.strictSame(fileName, 'example7/test'); + }); + + watcher.on('rm', (fileName) => { + test.strictSame(fileName, 'test'); + }); + + watcher.on('after', () => { + clearTimeout(timeout); + watcher.unwatch(targetPath); + cleanup(targetPath); + test.end(); + }); + + setTimeout(() => { + fs.rmdir(secondPath, (err) => { + test.error(err, 'Can not delete file'); + }); + }, WRITE_TIMEOUT); +});