diff --git a/metawatch.js b/metawatch.js index ab6a560..1e64151 100644 --- a/metawatch.js +++ b/metawatch.js @@ -6,25 +6,39 @@ const { EventEmitter } = require('node:events'); const WATCH_TIMEOUT = 5000; +const isExcludedFile = (filePath, excludeExts, excludeFiles) => { + 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 = (dirPath, excludePaths) => { + 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.excludeExts = new Set(exts); + this.excludePaths = new Set(dirs); + this.excludeFiles = new Set(files); 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); - this.timer = null; - } + this.timer = null; this.sendQueue(); }, this.timeout); } @@ -34,49 +48,61 @@ 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) => { - const target = targetPath.endsWith(path.sep + fileName); - const filePath = target ? targetPath : path.join(targetPath, fileName); + watchDirectory(dirPath) { + if (this.watchers.has(dirPath)) return; + const { excludeExts, excludeFiles } = this; + const watcher = fs.watch(dirPath); + watcher.on('error', () => void this.unwatch(dirPath)); + watcher.on('change', (...args) => { + const fileName = args.pop(); + const target = dirPath.endsWith(path.sep + fileName); + const filePath = target ? dirPath : path.join(dirPath, fileName); + if (isExcludedFile(filePath, excludeExts, excludeFiles)) 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); }); }); - this.watchers.set(targetPath, watcher); + this.watchers.set(dirPath, watcher); } - watch(targetPath) { - const watcher = this.watchers.get(targetPath); - if (watcher) return; + unwatch(filePath) { + const watcher = this.watchers.get(filePath); + if (!watcher) return; + watcher.close(); + this.watchers.delete(filePath); + } + + watch(targetPath = process.cwd()) { + const { excludePaths } = this; + if (isExcludedDir(targetPath, excludePaths)) return this; fs.readdir(targetPath, { withFileTypes: true }, (err, files) => { - if (err) return; + if (err) return this; 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); + return this; }); - } - - unwatch(path) { - const watcher = this.watchers.get(path); - if (!watcher) return; - watcher.close(); - this.watchers.delete(path); + return this; } } diff --git a/test/unit.js b/test/unit.js index 15c60a8..4a71969 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,219 @@ 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'); + fs.openSync(filePath, 'a'); + + 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', (fileName) => { + if (process.platform === 'win32') { + test.strictSame(fileName.includes('example6'), true); + } else { + 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); +});