globwatcher
Version:
watch a set of files for changes (including create/delete) by glob patterns
584 lines (522 loc) • 20.2 kB
JavaScript
"use strict";
var _toConsumableArray = function (arr) { if (Array.isArray(arr)) { for (var i = 0, arr2 = Array(arr.length); i < arr.length; i++) arr2[i] = arr[i]; return arr2; } else { return Array.from(arr); } };
var _get = function get(object, property, receiver) { var desc = Object.getOwnPropertyDescriptor(object, property); if (desc === undefined) { var parent = Object.getPrototypeOf(object); if (parent === null) { return undefined; } else { return get(parent, property, receiver); } } else if ("value" in desc && desc.writable) { return desc.value; } else { var getter = desc.get; if (getter === undefined) { return undefined; } return getter.call(receiver); } };
var _inherits = function (subClass, superClass) { if (typeof superClass !== "function" && superClass !== null) { throw new TypeError("Super expression must either be null or a function, not " + typeof superClass); } subClass.prototype = Object.create(superClass && superClass.prototype, { constructor: { value: subClass, enumerable: false, writable: true, configurable: true } }); if (superClass) subClass.__proto__ = superClass; };
var _createClass = (function () { function defineProperties(target, props) { for (var key in props) { var prop = props[key]; prop.configurable = true; if (prop.value) prop.writable = true; } Object.defineProperties(target, props); } return function (Constructor, protoProps, staticProps) { if (protoProps) defineProperties(Constructor.prototype, protoProps); if (staticProps) defineProperties(Constructor, staticProps); return Constructor; }; })();
var _classCallCheck = function (instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } };
var events = require("events");
var fs = require("fs");
var glob = require("glob");
var minimatch = require("minimatch");
var path = require("path");
var Promise = require("bluebird");
var util = require("util");
var _ = require("lodash");
glob = Promise.promisify(glob);
var readdir = Promise.promisify(fs.readdir);
var FileWatcher = require("./filewatcher").FileWatcher;
// FIXME this should probably be in a minimatch wrapper class
function folderMatchesMinimatchPrefix(folderSegments, minimatchSet) {
for (var i = 0; i < folderSegments.length; i++) {
var segment = folderSegments[i];
var miniSegment = minimatchSet[i];
if (miniSegment == minimatch.GLOBSTAR) {
return true;
}if (typeof miniSegment == "string") {
if (miniSegment != segment) {
return false;
}
} else {
if (!miniSegment.test(segment)) {
return false;
}
}
}
return true;
}
exports.folderMatchesMinimatchPrefix = folderMatchesMinimatchPrefix;
// sometimes helpful for my own debugging.
function debugWithTimestamp(message) {
var now = Date.now();
var timestamp = new Date(now).toString().slice(16, 24) + "." + (1000 + now % 1000).toString().slice(1);
console.log(timestamp + ": " + message);
}
exports.debugWithTimestamp = debugWithTimestamp;
// map (absolute) folder names, which are being folder-level watched, to a
// set of (absolute) filenames in that folder that are being file-level
// watched.
var WatchMap = (function () {
function WatchMap() {
_classCallCheck(this, WatchMap);
// map: folderName -> (filename -> true)
this.map = {};
}
_createClass(WatchMap, {
clear: {
value: function clear() {
this.map = {};
}
},
watchFolder: {
value: function watchFolder(folderName) {
if (this.map[folderName] == undefined) this.map[folderName] = {};
}
},
unwatchFolder: {
value: function unwatchFolder(folderName) {
delete this.map[folderName];
}
},
watchFile: {
value: function watchFile(filename, parent) {
if (this.map[parent] == undefined) this.map[parent] = {};
this.map[parent][filename] = true;
}
},
unwatchFile: {
value: function unwatchFile(filename, parent) {
delete this.map[parent][filename];
}
},
getFolders: {
value: function getFolders() {
return Object.keys(this.map);
}
},
getFilenames: {
value: function getFilenames(folderName) {
return Object.keys(this.map[folderName] || {});
}
},
getAllFilenames: {
value: function getAllFilenames() {
var _this = this;
var rv = [];
this.getFolders().forEach(function (folder) {
rv = rv.concat(_this.getFilenames(folder));
});
return rv;
}
},
getNestedFolders: {
value: function getNestedFolders(folderName) {
return this.getFolders().filter(function (f) {
return f.slice(0, folderName.length) == folderName;
});
}
},
watchingFolder: {
value: function watchingFolder(folderName) {
return this.map[folderName] != undefined;
}
},
watchingFile: {
value: function watchingFile(filename, parent) {
if (parent == null) parent = path.dirname(filename);
return this.map[parent] != null && this.map[parent][filename] != null;
}
},
toDebug: {
value: function toDebug() {
var _this = this;
var out = [];
Object.keys(this.map).sort().forEach(function (folder) {
out.push(folder);
Object.keys(_this.map[folder]).sort().forEach(function (filename) {
return out.push(" '- " + filename);
});
});
return out.join("\n") + "\n";
}
}
});
return WatchMap;
})();
function globwatcher(pattern, options) {
return new GlobWatcher(pattern, options);
}
exports.globwatcher = globwatcher;
var GlobWatcher = (function (_events$EventEmitter) {
function GlobWatcher(patterns) {
var options = arguments[1] === undefined ? {} : arguments[1];
_classCallCheck(this, GlobWatcher);
_get(Object.getPrototypeOf(GlobWatcher.prototype), "constructor", this).call(this);
this.closed = false;
this.cwd = options.cwd || process.cwd();
this.debounceInterval = options.debounceInterval || 10;
this.interval = options.interval || 250;
this.debug = options.debug || function () {
return null;
};
if (this.debug === true) this.debug = debugWithTimestamp;
this.persistent = options.persistent || false;
this.emitFolders = options.emitFolders || false;
this.watchMap = new WatchMap();
this.fileWatcher = new FileWatcher(options);
// map of (absolute) folderName -> FSWatcher
this.watchers = {};
// (ordered) list of glob patterns to watch
this.patterns = [];
// minimatch sets for our patterns
this.minimatchSets = [];
// set of folder watch events to check on after the debounce interval
this.checkQueue = {};
if (typeof patterns == "string") patterns = [patterns];
this.originalPatterns = patterns;
if (options.snapshot) {
this.restoreFrom(options.snapshot, patterns);
} else {
this.add.apply(this, _toConsumableArray(patterns));
}
}
_inherits(GlobWatcher, _events$EventEmitter);
_createClass(GlobWatcher, {
add: {
value: function add() {
var _this = this;
for (var _len = arguments.length, patterns = Array(_len), _key = 0; _key < _len; _key++) {
patterns[_key] = arguments[_key];
}
this.debug("add: " + util.inspect(patterns));
this.originalPatterns = this.originalPatterns.concat(patterns);
this.addPatterns(patterns);
this.ready = Promise.all(this.patterns.map(function (p) {
return glob(p, { nonegate: true }).then(function (files) {
files.forEach(function (filename) {
return _this.addWatch(filename);
});
});
})).then(function () {
_this.stopWatches();
_this.startWatches();
// give a little delay to wait for things to calm down
return Promise.delay(_this.debounceInterval);
}).then(function () {
_this.debug("add complete: " + util.inspect(patterns));
return _this;
});
}
},
close: {
value: function close() {
this.debug("close");
this.stopWatches();
this.watchMap.clear();
this.fileWatcher.close();
this.closed = true;
this.debug("/close");
}
},
check: {
// scan every covered folder again to see if there were any changes.
value: function check() {
var _this = this;
this.debug("-> check");
var folders = Object.keys(this.watchers).map(function (folderName) {
return _this.folderChanged(folderName);
});
return Promise.all([this.fileWatcher.check()].concat(folders)).then(function () {
_this.debug("<- check");
});
}
},
currentSet: {
// what files exist *right now* that match the watches?
value: function currentSet() {
return this.watchMap.getAllFilenames();
}
},
snapshot: {
// filename -> { mtime size }
value: function snapshot() {
var _this = this;
var state = {};
this.watchMap.getAllFilenames().forEach(function (filename) {
var w = _this.fileWatcher.watchFor(filename);
if (w) state[filename] = { mtime: w.mtime, size: w.size };
});
return state;
}
},
restoreFrom: {
// ----- internals:
// restore from a { filename -> { mtime size } } snapshot.
value: function restoreFrom(state, patterns) {
var _this = this;
this.addPatterns(patterns);
Object.keys(state).forEach(function (filename) {
var folderName = path.dirname(filename);
if (folderName != "/") folderName += "/";
_this.watchMap.watchFile(filename, folderName);
});
// now, start watches.
this.watchMap.getFolders().forEach(function (folderName) {
_this.watchFolder(folderName);
});
Object.keys(state).forEach(function (filename) {
_this.watchFile(filename, state[filename].mtime, state[filename].size);
});
// give a little delay to wait for things to calm down.
this.ready = Promise.delay(this.debounceInterval).then(function () {
_this.debug("restore complete: " + util.inspect(patterns));
return _this.check();
}).then(function () {
return _this;
});
}
},
addPatterns: {
value: function addPatterns(patterns) {
var _this = this;
patterns.forEach(function (p) {
p = _this.absolutePath(p);
if (_this.patterns.indexOf(p) < 0) _this.patterns.push(p);
});
this.minimatchSets = [];
this.patterns.forEach(function (p) {
_this.minimatchSets = _this.minimatchSets.concat(new minimatch.Minimatch(p, { nonegate: true }).set);
});
this.minimatchSets.forEach(function (set) {
return _this.watchPrefix(set);
});
}
},
watchPrefix: {
// make sure we are watching at least the non-glob prefix of this pattern,
// in case the pattern represents a folder that doesn't exist yet.
value: function watchPrefix(minimatchSet) {
var index = 0;
while (index < minimatchSet.length && typeof minimatchSet[index] == "string") index += 1;
if (index == minimatchSet.length) index -= 1;
var prefix = path.join.apply(path, ["/"].concat(_toConsumableArray(minimatchSet.slice(0, index))));
var parent = path.dirname(prefix);
// if the prefix doesn't exist, backtrack within reason (don't watch "/").
while (!fs.existsSync(prefix) && parent != path.dirname(parent)) {
prefix = path.dirname(prefix);
parent = path.dirname(parent);
}
if (fs.existsSync(prefix)) {
if (prefix[prefix.length - 1] != "/") prefix += "/";
this.watchMap.watchFolder(prefix);
}
}
},
absolutePath: {
value: function absolutePath(p) {
return p[0] == "/" ? p : path.join(this.cwd, p);
}
},
isMatch: {
value: function isMatch(filename) {
return _.some(this.patterns, function (p) {
return minimatch(filename, p, { nonegate: true });
});
}
},
addWatch: {
value: function addWatch(filename) {
var isdir = false;
try {
isdir = fs.statSync(filename).isDirectory();
} catch (error) {}
if (isdir) {
// watch whole folder
filename += "/";
this.watchMap.watchFolder(filename);
} else {
var _parent = path.dirname(filename);
if (_parent != "/") _parent += "/";
this.watchMap.watchFile(filename, _parent);
}
}
},
stopWatches: {
value: function stopWatches() {
var _this = this;
_.forIn(this.watchers, function (watcher, x) {
watcher.close();
});
this.watchMap.getFolders().forEach(function (folderName) {
_this.watchMap.getFilenames(folderName).forEach(function (filename) {
return _this.fileWatcher.unwatch(filename);
});
});
this.watchers = {};
this.closed = true;
}
},
startWatches: {
value: function startWatches() {
var _this = this;
this.watchMap.getFolders().forEach(function (folderName) {
_this.watchFolder(folderName);
_this.watchMap.getFilenames(folderName).forEach(function (filename) {
if (filename[filename.length - 1] != "/") _this.watchFile(filename);
});
});
this.closed = false;
}
},
watchFolder: {
value: function watchFolder(folderName) {
var _this = this;
this.debug("watch: " + folderName);
try {
this.watchers[folderName] = fs.watch(folderName, { persistent: this.persistent, recursive: false }, function (event) {
_this.debug("watch event: " + folderName);
_this.checkQueue[folderName] = true;
// wait a short interval to make sure the new folder has some staying power.
setTimeout(function () {
return _this.scanQueue();
}, _this.debounceInterval);
});
} catch (error) {}
}
},
scanQueue: {
value: function scanQueue() {
var _this = this;
var folders = Object.keys(this.checkQueue);
this.checkQueue = {};
folders.forEach(function (f) {
return _this.folderChanged(f);
});
}
},
watchFile: {
value: function watchFile(filename) {
var _this = this;
var mtime = arguments[1] === undefined ? null : arguments[1];
var size = arguments[2] === undefined ? null : arguments[2];
this.debug("watchFile: " + filename);
// FIXME @persistent @interval
this.fileWatcher.watch(filename, mtime, size).on("changed", function () {
_this.debug("watchFile event: " + filename);
_this.emit("changed", filename);
});
}
},
folderChanged: {
value: function folderChanged(folderName) {
var _this = this;
// keep a scoreboard so we can avoid calling readdir() on a folder while
// we're literally in the middle of a readdir() on that folder already.
if (!this.folderChangedScoreboard) this.folderChangedScoreboard = {};
if (this.folderChangedScoreboard[folderName]) {
return Promise.resolve();
}this.folderChangedScoreboard[folderName] = true;
this.debug("-> check folder: " + folderName);
if (this.closed) {
this.debug("<- check n/m, closed");
return Promise.resolve();
}
return readdir(folderName)["catch"](function (error) {
delete _this.folderChangedScoreboard[folderName];
if (_this.emitFolders) _this.emit("deleted", folderName);
_this.debug(" ERR: " + error);
return [];
}).then(function (current) {
delete _this.folderChangedScoreboard[folderName];
if (_this.closed) {
_this.debug("<- check n/m, closed");
return Promise.resolve();
}
// add "/" to folders
current = current.map(function (filename) {
filename = path.join(folderName, filename);
try {
if (fs.statSync(filename).isDirectory()) filename += "/";
} catch (error) {}
return filename;
});
var previous = _this.watchMap.getFilenames(folderName);
if (previous.length == 0 && _this.emitFolders) _this.emit("added", folderName);
// deleted files/folders
previous.filter(function (x) {
return current.indexOf(x) < 0;
}).map(function (f) {
f[f.length - 1] == "/" ? _this.folderDeleted(f) : _this.fileDeleted(f);
});
// new files/folders
return Promise.all(current.filter(function (x) {
return previous.indexOf(x) < 0;
}).map(function (f) {
return f[f.length - 1] == "/" ? _this.folderAdded(f) : _this.fileAdded(f, folderName);
}));
}).then(function () {
_this.debug("<- check folder: " + folderName);
});
}
},
fileDeleted: {
value: function fileDeleted(filename) {
this.debug("file deleted: " + filename);
var parent = path.dirname(filename);
if (parent != "/") parent += "/";
if (this.watchMap.watchingFile(filename, parent)) {
fs.unwatchFile(filename);
this.watchMap.unwatchFile(filename, parent);
}
this.emit("deleted", filename);
}
},
folderDeleted: {
value: function folderDeleted(folderName) {
var _this = this;
// this is trouble, bartman-style, because it may be the only indication
// we get that an entire subtree is gone. recurse through them, marking
// everything as dead.
this.debug("folder deleted: " + folderName);
// getNestedFolders() also includes this folder (folderName).
this.watchMap.getNestedFolders(folderName).forEach(function (folder) {
_this.watchMap.getFilenames(folder).forEach(function (filename) {
return _this.fileDeleted(filename);
});
if (_this.watchers[folder]) {
_this.watchers[folder].close();
delete _this.watchers[folder];
}
_this.watchMap.unwatchFolder(folder);
});
}
},
fileAdded: {
value: function fileAdded(filename, folderName) {
if (!this.isMatch(filename)) {
return Promise.resolve();
}this.debug("file added: " + filename);
this.watchMap.watchFile(filename, folderName);
this.watchFile(filename);
this.emit("added", filename);
return Promise.resolve();
}
},
folderAdded: {
value: function folderAdded(folderName) {
// if it potentially matches the prefix of a glob we're watching, start
// watching it, and recursively check for new files.
if (!this.folderIsInteresting(folderName)) {
return Promise.resolve();
}this.debug("folder added: " + folderName);
this.watchMap.watchFolder(folderName);
this.watchFolder(folderName);
return this.folderChanged(folderName);
}
},
folderIsInteresting: {
// does this folder match the prefix for an existing watch-pattern?
value: function folderIsInteresting(folderName) {
var folderSegments = folderName.split("/");
folderSegments = folderSegments.slice(0, folderSegments.length - 1);
return _.some(this.minimatchSets, function (set) {
return folderMatchesMinimatchPrefix(folderSegments, set);
});
}
}
});
return GlobWatcher;
})(events.EventEmitter);
// don't worry about it.
// never mind.
// file vanished before we could stat it!
//# sourceMappingURL=globwatcher.js.map