You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
375 lines
8.7 KiB
375 lines
8.7 KiB
// 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' || // Workaround Windows node issue #4337. |
|
(error.code === 'EPERM' && platform === 'win32') |
|
); |
|
}
|
|
|