UNPKG

globwatcher

Version:

watch a set of files for changes (including create/delete) by glob patterns

584 lines (522 loc) 20.2 kB
"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