// vendored from https://github.com/amasad/sane/blob/64ff3a870c42e84f744086884bf55a4f9c22d376/src/node_watcher.js 'use strict'; const EventEmitter = require('events').EventEmitter; const fs = require('fs'); const platform = require('os').platform(); const path = require('path'); const common = require('./common'); /** * Constants */ const DEFAULT_DELAY = common.DEFAULT_DELAY; const CHANGE_EVENT = common.CHANGE_EVENT; const DELETE_EVENT = common.DELETE_EVENT; const ADD_EVENT = common.ADD_EVENT; const ALL_EVENT = common.ALL_EVENT; /** * Export `NodeWatcher` class. * Watches `dir`. * * @class NodeWatcher * @param {String} dir * @param {Object} opts * @public */ module.exports = class NodeWatcher extends EventEmitter { constructor(dir, opts) { super(); common.assignOptions(this, opts); this.watched = Object.create(null); this.changeTimers = Object.create(null); this.dirRegistery = Object.create(null); this.root = path.resolve(dir); this.watchdir = this.watchdir.bind(this); this.register = this.register.bind(this); this.checkedEmitError = this.checkedEmitError.bind(this); this.watchdir(this.root); common.recReaddir( this.root, this.watchdir, this.register, this.emit.bind(this, 'ready'), this.checkedEmitError, this.ignored ); } /** * Register files that matches our globs to know what to type of event to * emit in the future. * * Registery looks like the following: * * dirRegister => Map { * dirpath => Map { * filename => true * } * } * * @param {string} filepath * @return {boolean} whether or not we have registered the file. * @private */ register(filepath) { const relativePath = path.relative(this.root, filepath); if ( !common.isFileIncluded(this.globs, this.dot, this.doIgnore, relativePath) ) { return false; } const dir = path.dirname(filepath); if (!this.dirRegistery[dir]) { this.dirRegistery[dir] = Object.create(null); } const filename = path.basename(filepath); this.dirRegistery[dir][filename] = true; return true; } /** * Removes a file from the registery. * * @param {string} filepath * @private */ unregister(filepath) { const dir = path.dirname(filepath); if (this.dirRegistery[dir]) { const filename = path.basename(filepath); delete this.dirRegistery[dir][filename]; } } /** * Removes a dir from the registery. * * @param {string} dirpath * @private */ unregisterDir(dirpath) { if (this.dirRegistery[dirpath]) { delete this.dirRegistery[dirpath]; } } /** * Checks if a file or directory exists in the registery. * * @param {string} fullpath * @return {boolean} * @private */ registered(fullpath) { const dir = path.dirname(fullpath); return ( this.dirRegistery[fullpath] || (this.dirRegistery[dir] && this.dirRegistery[dir][path.basename(fullpath)]) ); } /** * Emit "error" event if it's not an ignorable event * * @param error * @private */ checkedEmitError(error) { if (!isIgnorableFileError(error)) { this.emit('error', error); } } /** * Watch a directory. * * @param {string} dir * @private */ watchdir(dir) { if (this.watched[dir]) { return; } const watcher = fs.watch( dir, { persistent: true }, this.normalizeChange.bind(this, dir) ); this.watched[dir] = watcher; watcher.on('error', this.checkedEmitError); if (this.root !== dir) { this.register(dir); } } /** * Stop watching a directory. * * @param {string} dir * @private */ stopWatching(dir) { if (this.watched[dir]) { this.watched[dir].close(); delete this.watched[dir]; } } /** * End watching. * * @public */ close() { Object.keys(this.watched).forEach(this.stopWatching, this); this.removeAllListeners(); return Promise.resolve(); } /** * On some platforms, as pointed out on the fs docs (most likely just win32) * the file argument might be missing from the fs event. Try to detect what * change by detecting if something was deleted or the most recent file change. * * @param {string} dir * @param {string} event * @param {string} file * @public */ detectChangedFile(dir, event, callback) { if (!this.dirRegistery[dir]) { return; } let found = false; let closest = { mtime: 0 }; let c = 0; Object.keys(this.dirRegistery[dir]).forEach(function (file, i, arr) { fs.lstat(path.join(dir, file), (error, stat) => { if (found) { return; } if (error) { if (isIgnorableFileError(error)) { found = true; callback(file); } else { this.emit('error', error); } } else { if (stat.mtime > closest.mtime) { stat.file = file; closest = stat; } if (arr.length === ++c) { callback(closest.file); } } }); }, this); } /** * Normalize fs events and pass it on to be processed. * * @param {string} dir * @param {string} event * @param {string} file * @public */ normalizeChange(dir, event, file) { if (!file) { this.detectChangedFile(dir, event, actualFile => { if (actualFile) { this.processChange(dir, event, actualFile); } }); } else { this.processChange(dir, event, path.normalize(file)); } } /** * Process changes. * * @param {string} dir * @param {string} event * @param {string} file * @public */ processChange(dir, event, file) { const fullPath = path.join(dir, file); const relativePath = path.join(path.relative(this.root, dir), file); fs.lstat(fullPath, (error, stat) => { if (error && error.code !== 'ENOENT') { this.emit('error', error); } else if (!error && stat.isDirectory()) { // win32 emits usless change events on dirs. if (event !== 'change') { this.watchdir(fullPath); if ( common.isFileIncluded( this.globs, this.dot, this.doIgnore, relativePath ) ) { this.emitEvent(ADD_EVENT, relativePath, stat); } } } else { const registered = this.registered(fullPath); if (error && error.code === 'ENOENT') { this.unregister(fullPath); this.stopWatching(fullPath); this.unregisterDir(fullPath); if (registered) { this.emitEvent(DELETE_EVENT, relativePath); } } else if (registered) { this.emitEvent(CHANGE_EVENT, relativePath, stat); } else { if (this.register(fullPath)) { this.emitEvent(ADD_EVENT, relativePath, stat); } } } }); } /** * Triggers a 'change' event after debounding it to take care of duplicate * events on os x. * * @private */ emitEvent(type, file, stat) { const key = type + '-' + file; const addKey = ADD_EVENT + '-' + file; if (type === CHANGE_EVENT && this.changeTimers[addKey]) { // Ignore the change event that is immediately fired after an add event. // (This happens on Linux). return; } clearTimeout(this.changeTimers[key]); this.changeTimers[key] = setTimeout(() => { delete this.changeTimers[key]; if (type === ADD_EVENT && stat.isDirectory()) { // Recursively emit add events and watch for sub-files/folders common.recReaddir( path.resolve(this.root, file), function emitAddDir(dir, stats) { this.watchdir(dir); this.rawEmitEvent(ADD_EVENT, path.relative(this.root, dir), stats); }.bind(this), function emitAddFile(file, stats) { this.register(file); this.rawEmitEvent(ADD_EVENT, path.relative(this.root, file), stats); }.bind(this), function endCallback() {}, this.checkedEmitError, this.ignored ); } else { this.rawEmitEvent(type, file, stat); } }, DEFAULT_DELAY); } /** * Actually emit the events */ rawEmitEvent(type, file, stat) { this.emit(type, file, this.root, stat); this.emit(ALL_EVENT, type, file, this.root, stat); } }; /** * Determine if a given FS error can be ignored * * @private */ function isIgnorableFileError(error) { return ( error.code === 'ENOENT' || (error.code === 'EPERM' && platform === 'win32') ); }