node_watcher.js
  1  'use strict';
  2  
  3  const fs = require('fs');
  4  const path = require('path');
  5  const common = require('./common');
  6  const platform = require('os').platform();
  7  const EventEmitter = require('events').EventEmitter;
  8  
  9  /**
 10   * Constants
 11   */
 12  
 13  const DEFAULT_DELAY = common.DEFAULT_DELAY;
 14  const CHANGE_EVENT = common.CHANGE_EVENT;
 15  const DELETE_EVENT = common.DELETE_EVENT;
 16  const ADD_EVENT = common.ADD_EVENT;
 17  const ALL_EVENT = common.ALL_EVENT;
 18  
 19  /**
 20   * Export `NodeWatcher` class.
 21   * Watches `dir`.
 22   *
 23   * @class NodeWatcher
 24   * @param {String} dir
 25   * @param {Object} opts
 26   * @public
 27   */
 28  
 29  module.exports = class NodeWatcher extends EventEmitter {
 30    constructor(dir, opts) {
 31      super();
 32  
 33      common.assignOptions(this, opts);
 34  
 35      this.watched = Object.create(null);
 36      this.changeTimers = Object.create(null);
 37      this.dirRegistery = Object.create(null);
 38      this.root = path.resolve(dir);
 39      this.watchdir = this.watchdir.bind(this);
 40      this.register = this.register.bind(this);
 41      this.checkedEmitError = this.checkedEmitError.bind(this);
 42  
 43      this.watchdir(this.root);
 44      common.recReaddir(
 45        this.root,
 46        this.watchdir,
 47        this.register,
 48        this.emit.bind(this, 'ready'),
 49        this.checkedEmitError,
 50        this.ignored
 51      );
 52    }
 53  
 54    /**
 55     * Register files that matches our globs to know what to type of event to
 56     * emit in the future.
 57     *
 58     * Registery looks like the following:
 59     *
 60     *  dirRegister => Map {
 61     *    dirpath => Map {
 62     *       filename => true
 63     *    }
 64     *  }
 65     *
 66     * @param {string} filepath
 67     * @return {boolean} whether or not we have registered the file.
 68     * @private
 69     */
 70  
 71    register(filepath) {
 72      let relativePath = path.relative(this.root, filepath);
 73      if (
 74        !common.isFileIncluded(this.globs, this.dot, this.doIgnore, relativePath)
 75      ) {
 76        return false;
 77      }
 78  
 79      let dir = path.dirname(filepath);
 80      if (!this.dirRegistery[dir]) {
 81        this.dirRegistery[dir] = Object.create(null);
 82      }
 83  
 84      let filename = path.basename(filepath);
 85      this.dirRegistery[dir][filename] = true;
 86  
 87      return true;
 88    }
 89  
 90    /**
 91     * Removes a file from the registery.
 92     *
 93     * @param {string} filepath
 94     * @private
 95     */
 96  
 97    unregister(filepath) {
 98      let dir = path.dirname(filepath);
 99      if (this.dirRegistery[dir]) {
100        let filename = path.basename(filepath);
101        delete this.dirRegistery[dir][filename];
102      }
103    }
104  
105    /**
106     * Removes a dir from the registery.
107     *
108     * @param {string} dirpath
109     * @private
110     */
111  
112    unregisterDir(dirpath) {
113      if (this.dirRegistery[dirpath]) {
114        delete this.dirRegistery[dirpath];
115      }
116    }
117  
118    /**
119     * Checks if a file or directory exists in the registery.
120     *
121     * @param {string} fullpath
122     * @return {boolean}
123     * @private
124     */
125  
126    registered(fullpath) {
127      let dir = path.dirname(fullpath);
128      return (
129        this.dirRegistery[fullpath] ||
130        (this.dirRegistery[dir] &&
131          this.dirRegistery[dir][path.basename(fullpath)])
132      );
133    }
134  
135    /**
136     * Emit "error" event if it's not an ignorable event
137     *
138     * @param error
139     * @private
140     */
141    checkedEmitError(error) {
142      if (!isIgnorableFileError(error)) {
143        this.emit('error', error);
144      }
145    }
146  
147    /**
148     * Watch a directory.
149     *
150     * @param {string} dir
151     * @private
152     */
153  
154    watchdir(dir) {
155      if (this.watched[dir]) {
156        return;
157      }
158  
159      let watcher = fs.watch(
160        dir,
161        { persistent: true },
162        this.normalizeChange.bind(this, dir)
163      );
164      this.watched[dir] = watcher;
165  
166      watcher.on('error', this.checkedEmitError);
167  
168      if (this.root !== dir) {
169        this.register(dir);
170      }
171    }
172  
173    /**
174     * Stop watching a directory.
175     *
176     * @param {string} dir
177     * @private
178     */
179  
180    stopWatching(dir) {
181      if (this.watched[dir]) {
182        this.watched[dir].close();
183        delete this.watched[dir];
184      }
185    }
186  
187    /**
188     * End watching.
189     *
190     * @public
191     */
192  
193    close(callback) {
194      Object.keys(this.watched).forEach(this.stopWatching, this);
195      this.removeAllListeners();
196      if (typeof callback === 'function') {
197        setImmediate(callback.bind(null, null, true));
198      }
199    }
200  
201    /**
202     * On some platforms, as pointed out on the fs docs (most likely just win32)
203     * the file argument might be missing from the fs event. Try to detect what
204     * change by detecting if something was deleted or the most recent file change.
205     *
206     * @param {string} dir
207     * @param {string} event
208     * @param {string} file
209     * @public
210     */
211  
212    detectChangedFile(dir, event, callback) {
213      if (!this.dirRegistery[dir]) {
214        return;
215      }
216  
217      let found = false;
218      let closest = { mtime: 0 };
219      let c = 0;
220      Object.keys(this.dirRegistery[dir]).forEach(function(file, i, arr) {
221        fs.lstat(
222          path.join(dir, file),
223          function(error, stat) {
224            if (found) {
225              return;
226            }
227  
228            if (error) {
229              if (isIgnorableFileError(error)) {
230                found = true;
231                callback(file);
232              } else {
233                this.emit('error', error);
234              }
235            } else {
236              if (stat.mtime > closest.mtime) {
237                stat.file = file;
238                closest = stat;
239              }
240              if (arr.length === ++c) {
241                callback(closest.file);
242              }
243            }
244          }.bind(this)
245        );
246      }, this);
247    }
248  
249    /**
250     * Normalize fs events and pass it on to be processed.
251     *
252     * @param {string} dir
253     * @param {string} event
254     * @param {string} file
255     * @public
256     */
257  
258    normalizeChange(dir, event, file) {
259      if (!file) {
260        this.detectChangedFile(
261          dir,
262          event,
263          function(actualFile) {
264            if (actualFile) {
265              this.processChange(dir, event, actualFile);
266            }
267          }.bind(this)
268        );
269      } else {
270        this.processChange(dir, event, path.normalize(file));
271      }
272    }
273  
274    /**
275     * Process changes.
276     *
277     * @param {string} dir
278     * @param {string} event
279     * @param {string} file
280     * @public
281     */
282  
283    processChange(dir, event, file) {
284      let fullPath = path.join(dir, file);
285      let relativePath = path.join(path.relative(this.root, dir), file);
286  
287      fs.lstat(
288        fullPath,
289        function(error, stat) {
290          if (error && error.code !== 'ENOENT') {
291            this.emit('error', error);
292          } else if (!error && stat.isDirectory()) {
293            // win32 emits usless change events on dirs.
294            if (event !== 'change') {
295              this.watchdir(fullPath);
296              if (
297                common.isFileIncluded(
298                  this.globs,
299                  this.dot,
300                  this.doIgnore,
301                  relativePath
302                )
303              ) {
304                this.emitEvent(ADD_EVENT, relativePath, stat);
305              }
306            }
307          } else {
308            let registered = this.registered(fullPath);
309            if (error && error.code === 'ENOENT') {
310              this.unregister(fullPath);
311              this.stopWatching(fullPath);
312              this.unregisterDir(fullPath);
313              if (registered) {
314                this.emitEvent(DELETE_EVENT, relativePath);
315              }
316            } else if (registered) {
317              this.emitEvent(CHANGE_EVENT, relativePath, stat);
318            } else {
319              if (this.register(fullPath)) {
320                this.emitEvent(ADD_EVENT, relativePath, stat);
321              }
322            }
323          }
324        }.bind(this)
325      );
326    }
327  
328    /**
329     * Triggers a 'change' event after debounding it to take care of duplicate
330     * events on os x.
331     *
332     * @private
333     */
334  
335    emitEvent(type, file, stat) {
336      let key = type + '-' + file;
337      let addKey = ADD_EVENT + '-' + file;
338      if (type === CHANGE_EVENT && this.changeTimers[addKey]) {
339        // Ignore the change event that is immediately fired after an add event.
340        // (This happens on Linux).
341        return;
342      }
343      clearTimeout(this.changeTimers[key]);
344      this.changeTimers[key] = setTimeout(
345        function() {
346          delete this.changeTimers[key];
347          if (type === ADD_EVENT && stat.isDirectory()) {
348            // Recursively emit add events and watch for sub-files/folders
349            common.recReaddir(
350              path.resolve(this.root, file),
351              function emitAddDir(dir, stats) {
352                this.watchdir(dir);
353                this.rawEmitEvent(
354                  ADD_EVENT,
355                  path.relative(this.root, dir),
356                  stats
357                );
358              }.bind(this),
359              function emitAddFile(file, stats) {
360                this.register(file);
361                this.rawEmitEvent(
362                  ADD_EVENT,
363                  path.relative(this.root, file),
364                  stats
365                );
366              }.bind(this),
367              function endCallback() {},
368              this.checkedEmitError,
369              this.ignored
370            );
371          } else {
372            this.rawEmitEvent(type, file, stat);
373          }
374        }.bind(this),
375        DEFAULT_DELAY
376      );
377    }
378  
379    /**
380     * Actually emit the events
381     */
382    rawEmitEvent(type, file, stat) {
383      this.emit(type, file, this.root, stat);
384      this.emit(ALL_EVENT, type, file, this.root, stat);
385    }
386  };
387  /**
388   * Determine if a given FS error can be ignored
389   *
390   * @private
391   */
392  function isIgnorableFileError(error) {
393    return (
394      error.code === 'ENOENT' ||
395      // Workaround Windows node issue #4337.
396      (error.code === 'EPERM' && platform === 'win32')
397    );
398  }