'use strict'; var debug = require('debug')('wt'); var path = require('path'); var fs = require('fs'); var Base = require('sdk-base'); var util = require('util'); var ndir = require('ndir'); module.exports = Watcher; module.exports.Watcher = Watcher; /** * Watcher * * @param {Object} options * - {Boolean} [ignoreNodeModules] ignore node_modules or not, default is `true` * - {Boolean} [ignoreHidden] ignore hidden file or not, default is `true` * - {Number} [rewatchInterval] auto rewatch root dir interval, * default is `0`, don't rewatch. * @param {Function} [done], watch all dirs done callback. */ function Watcher(options) { Base.call(this); // http://nodejs.org/dist/v0.11.12/docs/api/fs.html#fs_caveats // The recursive option is currently supported on OS X. // Only FSEvents supports this type of file watching // so it is unlikely any additional platforms will be added soon. options = options || {}; if (options.ignoreHidden === undefined || options.ignoreHidden === null) { options.ignoreHidden = true; } if (options.ignoreNodeModules === undefined || options.ignoreNodeModules === null){ options.ignoreNodeModules = true; } this._ignoreHidden = !!options.ignoreHidden; this._ignoreNodeModules = !!options.ignoreNodeModules; this._rewatchInterval = options.rewatchInterval; this.watchOptions = { persistent: true, recursive: false, // so we dont use this features }; this._watchers = {}; this._rootDirs = []; this._rewatchTimer = null; if (this._rewatchInterval && typeof this._rewatchInterval === 'number') { this._rewatchTimer = setInterval(this._rewatchRootDirs.bind(this), this._rewatchInterval); debug('start rewatch timer every %dms', this._rewatchInterval); } } Watcher.watch = function (dirs, options, done) { // watch(dirs, done); if (typeof options === 'function') { done = options; options = null; } var watcher = new Watcher(options).watch(dirs); if (typeof done === 'function') { var count = Array.isArray(dirs) ? dirs.length : 1; watcher.on('watch', function () { count--; if (count === 0) { done(); } }); } return watcher; }; util.inherits(Watcher, Base); var proto = Watcher.prototype; proto.isWatching = function (dir) { return !!this._watchers[dir]; }; /** * Start watch dir(s) * * @param {Array|String} dirs: dir path or path list * @return {this} */ proto.watch = function (dirs) { if (!Array.isArray(dirs)) { dirs = [dirs]; } debug('watch(%j)', dirs); for (var i = 0; i < dirs.length; i++) { var dir = dirs[i]; this._watchDir(dir); if (this._rootDirs.indexOf(dir) === -1) { this._rootDirs.push(dir); } } return this; }; proto.unwatch = function (dirs) { if (!Array.isArray(dirs)) { dirs = [dirs]; } for (var i = 0; i < dirs.length; i++) { var dir = dirs[i]; this._unwatchDir(dir); if (this._rootDirs.indexOf(dir) !== -1) { // remove from root dirs this._rootDirs.splice(this._rootDirs.indexOf(dir), 1); } } return this; }; proto.close = function () { for (var k in this._watchers) { this._watchers[k].close(); this._watchers[k].removeAllListeners(); this._watchers[k] = null; } this._watchers = {}; // 等待一个事件循环后再移除所有时间,确保 watcher 的 error 事件还能被捕获 setImmediate(function () { this.removeAllListeners(); }.bind(this)); if (this._rewatchTimer) { clearInterval(this._rewatchTimer); this._rewatchTimer = null; } }; proto._rewatchRootDirs = function () { for (var i = 0; i < this._rootDirs.length; i++) { var dir = this._rootDirs[i]; // watcher missing, meaning dir was deleted // try to rewatch again this._watchDirIfExists(dir); } }; proto._watchDirIfExists = function (dir) { var that = this; fs.stat(dir, function (err, stat) { debug('[watchDirIfExists] %s, error: %s, exists: %s', dir, err, !!stat); if (stat && stat.isDirectory()) { if (!that._watchers[dir]) { // watch again! that._watchDir(dir); } } else if (!stat) { // not exists, close watcher that._unwatchDir(dir); } }); }; proto._watchDir = function (dir) { var watchers = this._watchers; var that = this; debug('walking %s...', dir); ndir.walk(dir).on('dir', function (dirpath) { if (path.basename(dirpath)[0] === '.' && that._ignoreHidden) { debug('ignore hidden dir: %s', dirpath); return; } if (path.basename(dirpath) === 'node_modules' && that._ignoreNodeModules){ debug('ignore node_modules dir: %s', dirpath); return; } if (watchers[dirpath]) { debug('%s exists', dirpath); return; } debug('fs.watch(%s) start...', dirpath); var watcher; try { watcher = fs.watch(dirpath, that.watchOptions, that._handle.bind(that, dirpath)); } catch (err) { err.dir = dirpath; that.emit('error', err); return; } watchers[dirpath] = watcher; watcher.once('error', that._onWatcherError.bind(that, dirpath)); }).on('error', function (err) { err.dir = dir; that.emit('error', err); }).on('end', function () { debug('_watchDir(%s) done', dir); // debug('now watching %s', Object.keys(that._watchers)); that.emit('watch', dir); }); return this; }; proto._unwatchDir = function (dir) { var watcher = this._watchers[dir]; debug('unwatch %s, watcher exists: %s', dir, !!watcher); if (watcher) { watcher.close(); watcher.removeAllListeners(); delete this._watchers[dir]; this.emit('unwatch', dir); } // should close all subdir watchers too var subdirs = Object.keys(this._watchers); for (var i = 0; i < subdirs.length; i++) { var subdir = subdirs[i]; if (subdir.indexOf(dir + '/') === 0) { // close subdir watcher watcher = this._watchers[subdir]; watcher.close(); watcher.removeAllListeners(); delete this._watchers[subdir]; debug('close subdir %s watcher by %s', subdir, dir); } } }; proto._onWatcherError = function (dir, err) { this._unwatchDir(dir); err.dir = dir; this.emit('error', err); }; proto._handle = function (root, event, name) { var that = this; if (!name) { debug('[WARNING] event:%j, name:%j not exists on %s', root); return; } if (name[0] === '.' && this._ignoreHidden) { debug('ignore %s on %s/%s', event, root, name); return; } if (name === 'node_modules' && this._ignoreNodeModules) { debug('ignore %s on %s/%s', event, root, name); return; } // check root stat fs.exists(root, function (exists) { if (!exists) { debug('[handle] %s %s on %s, root not exists', event, name, root); that._handleChange({ event: event, path: path.join(root, name), stat: null, remove: true, isDirectory: false, isFile: false, }); // linux event, dir self remove, will fire `rename with dir name itself` if (that._watchers[root]) { debug('[handle] fire root:%s %s by %s', root, event, name); that._handleChange({ event: event, path: root, stat: null, remove: true, isDirectory: true, isFile: false, }); } return; } // children change debug('[handle] %s %s on %s, root exists', event, name, root); var fullpath = path.join(root, name); fs.stat(fullpath, function (err, stat) { var info = { event: event, path: fullpath, stat: stat, remove: false, isDirectory: stat && stat.isDirectory() || false, isFile: stat && stat.isFile() || false, }; if (err) { if (err.code === 'ENOENT') { info.remove = true; } } if (event === 'change' && info.remove) { // this should be a fs.watch bug debug('[WARNING] %s on %s, but file not exists, ignore this', event, fullpath); return; } that._handleChange(info); }); }); }; proto._handleChange = function (info) { debug('_handleChange(%j)', info); var that = this; if (info.remove) { var watcher = that._watchers[info.path]; if (watcher) { // close the exists watcher info.isDirectory = true; that._unwatchDir(info.path); } } else if (info.isDirectory) { var watcher = that._watchers[info.path]; if (!watcher) { // add new watcher that._watchDir(info.path); } } that.emit('all', info); if (info.remove) { debug('[remove event] %s, isDirectory: %s', info.path, info.isDirectory); that.emit('remove', info); } else if (info.isFile) { debug('[file change event] %s', info.path); that.emit('file', info); } else if (info.isDirectory) { debug('[dir change envet] %s', info.path); that.emit('dir', info); } };