UNPKG

10.9 kBJavaScriptView Raw
1/*
2 * gaze
3 * https://github.com/shama/gaze
4 *
5 * Copyright (c) 2013 Kyle Robinson Young
6 * Licensed under the MIT license.
7 */
8
9'use strict';
10
11// libs
12var util = require('util');
13var EE = require('events').EventEmitter;
14var fs = require('fs');
15var path = require('path');
16var globule = require('globule');
17var helper = require('./helper');
18
19// globals
20var delay = 10;
21
22// `Gaze` EventEmitter object to return in the callback
23function Gaze(patterns, opts, done) {
24 var self = this;
25 EE.call(self);
26
27 // If second arg is the callback
28 if (typeof opts === 'function') {
29 done = opts;
30 opts = {};
31 }
32
33 // Default options
34 opts = opts || {};
35 opts.mark = true;
36 opts.interval = opts.interval || 100;
37 opts.debounceDelay = opts.debounceDelay || 500;
38 opts.cwd = opts.cwd || process.cwd();
39 this.options = opts;
40
41 // Default done callback
42 done = done || function() {};
43
44 // Remember our watched dir:files
45 this._watched = Object.create(null);
46
47 // Store watchers
48 this._watchers = Object.create(null);
49
50 // Store watchFile listeners
51 this._pollers = Object.create(null);
52
53 // Store patterns
54 this._patterns = [];
55
56 // Cached events for debouncing
57 this._cached = Object.create(null);
58
59 // Set maxListeners
60 if (this.options.maxListeners) {
61 this.setMaxListeners(this.options.maxListeners);
62 Gaze.super_.prototype.setMaxListeners(this.options.maxListeners);
63 delete this.options.maxListeners;
64 }
65
66 // Initialize the watch on files
67 if (patterns) {
68 this.add(patterns, done);
69 }
70
71 return this;
72}
73util.inherits(Gaze, EE);
74
75// Main entry point. Start watching and call done when setup
76module.exports = function gaze(patterns, opts, done) {
77 return new Gaze(patterns, opts, done);
78};
79module.exports.Gaze = Gaze;
80
81// Override the emit function to emit `all` events
82// and debounce on duplicate events per file
83Gaze.prototype.emit = function() {
84 var self = this;
85 var args = arguments;
86
87 var e = args[0];
88 var filepath = args[1];
89 var timeoutId;
90
91 // If not added/deleted/changed/renamed then just emit the event
92 if (e.slice(-2) !== 'ed') {
93 Gaze.super_.prototype.emit.apply(self, args);
94 return this;
95 }
96
97 // Detect rename event, if added and previous deleted is in the cache
98 if (e === 'added') {
99 Object.keys(this._cached).forEach(function(oldFile) {
100 if (self._cached[oldFile].indexOf('deleted') !== -1) {
101 args[0] = e = 'renamed';
102 [].push.call(args, oldFile);
103 delete self._cached[oldFile];
104 return false;
105 }
106 });
107 }
108
109 // If cached doesnt exist, create a delay before running the next
110 // then emit the event
111 var cache = this._cached[filepath] || [];
112 if (cache.indexOf(e) === -1) {
113 helper.objectPush(self._cached, filepath, e);
114 clearTimeout(timeoutId);
115 timeoutId = setTimeout(function() {
116 delete self._cached[filepath];
117 }, this.options.debounceDelay);
118 // Emit the event and `all` event
119 Gaze.super_.prototype.emit.apply(self, args);
120 Gaze.super_.prototype.emit.apply(self, ['all', e].concat([].slice.call(args, 1)));
121 }
122
123 return this;
124};
125
126// Close watchers
127Gaze.prototype.close = function(_reset) {
128 var self = this;
129 _reset = _reset === false ? false : true;
130 Object.keys(self._watchers).forEach(function(file) {
131 self._watchers[file].close();
132 });
133 self._watchers = Object.create(null);
134 Object.keys(this._watched).forEach(function(dir) {
135 self._unpollDir(dir);
136 });
137 if (_reset) {
138 self._watched = Object.create(null);
139 setTimeout(function() {
140 self.emit('end');
141 self.removeAllListeners();
142 }, delay + 100);
143 }
144 return self;
145};
146
147// Add file patterns to be watched
148Gaze.prototype.add = function(files, done) {
149 if (typeof files === 'string') { files = [files]; }
150 this._patterns = helper.unique.apply(null, [this._patterns, files]);
151 files = globule.find(this._patterns, this.options);
152 this._addToWatched(files);
153 this.close(false);
154 this._initWatched(done);
155};
156
157// Dont increment patterns and dont call done if nothing added
158Gaze.prototype._internalAdd = function(file, done) {
159 var files = [];
160 if (helper.isDir(file)) {
161 files = globule.find(this._patterns, this.options);
162 } else {
163 if (globule.isMatch(this._patterns, file, this.options)) {
164 files = [file];
165 }
166 }
167 if (files.length > 0) {
168 this._addToWatched(files);
169 this.close(false);
170 this._initWatched(done);
171 }
172};
173
174// Remove file/dir from `watched`
175Gaze.prototype.remove = function(file) {
176 var self = this;
177 if (this._watched[file]) {
178 // is dir, remove all files
179 this._unpollDir(file);
180 delete this._watched[file];
181 } else {
182 // is a file, find and remove
183 Object.keys(this._watched).forEach(function(dir) {
184 var index = self._watched[dir].indexOf(file);
185 if (index) {
186 self._unpollFile(file);
187 delete self._watched[dir][index];
188 return false;
189 }
190 });
191 }
192 if (this._watchers[file]) {
193 this._watchers[file].close();
194 }
195 return this;
196};
197
198// Return watched files
199Gaze.prototype.watched = function() {
200 return this._watched;
201};
202
203// Returns `watched` files with relative paths to process.cwd()
204Gaze.prototype.relative = function(dir, unixify) {
205 var self = this;
206 var relative = Object.create(null);
207 var relDir, relFile, unixRelDir;
208 var cwd = this.options.cwd || process.cwd();
209 if (dir === '') { dir = '.'; }
210 dir = helper.markDir(dir);
211 unixify = unixify || false;
212 Object.keys(this._watched).forEach(function(dir) {
213 relDir = path.relative(cwd, dir) + path.sep;
214 if (relDir === path.sep) { relDir = '.'; }
215 unixRelDir = unixify ? helper.unixifyPathSep(relDir) : relDir;
216 relative[unixRelDir] = self._watched[dir].map(function(file) {
217 relFile = path.relative(path.join(cwd, relDir) || '', file || '');
218 if (helper.isDir(file)) {
219 relFile = helper.markDir(relFile);
220 }
221 if (unixify) {
222 relFile = helper.unixifyPathSep(relFile);
223 }
224 return relFile;
225 });
226 });
227 if (dir && unixify) {
228 dir = helper.unixifyPathSep(dir);
229 }
230 return dir ? relative[dir] || [] : relative;
231};
232
233// Adds files and dirs to watched
234Gaze.prototype._addToWatched = function(files) {
235 for (var i = 0; i < files.length; i++) {
236 var file = files[i];
237 var filepath = path.resolve(this.options.cwd, file);
238
239 var dirname = (helper.isDir(file)) ? filepath : path.dirname(filepath);
240 dirname = helper.markDir(dirname);
241
242 if (file.slice(-1) === '/') { filepath += path.sep; }
243 helper.objectPush(this._watched, path.dirname(filepath) + path.sep, filepath);
244
245 // add folders into the mix
246 var readdir = fs.readdirSync(dirname);
247 for (var j = 0; j < readdir.length; j++) {
248 var dirfile = path.join(dirname, readdir[j]);
249 if (fs.statSync(dirfile).isDirectory()) {
250 helper.objectPush(this._watched, dirname, dirfile + path.sep);
251 }
252 }
253 }
254 return this;
255};
256
257Gaze.prototype._watchDir = function(dir, done) {
258 try {
259 this._watchers[dir] = fs.watch(dir, function(event) {
260 // race condition. Let's give the fs a little time to settle down. so we
261 // don't fire events on non existent files.
262 setTimeout(function() {
263 if (fs.existsSync(dir)) {
264 done(null, dir);
265 }
266 }, delay + 100);
267 });
268 } catch (err) {
269 return this._handleError(err);
270 }
271 return this;
272};
273
274Gaze.prototype._unpollFile = function(file) {
275 if (this._pollers[file]) {
276 fs.unwatchFile(file, this._pollers[file] );
277 delete this._pollers[file];
278 }
279 return this;
280};
281
282Gaze.prototype._unpollDir = function(dir) {
283 this._unpollFile(dir);
284 for (var i = 0; i < this._watched[dir].length; i++) {
285 this._unpollFile(this._watched[dir][i]);
286 }
287};
288
289Gaze.prototype._pollFile = function(file, done) {
290 var opts = { persistent: true, interval: this.options.interval };
291 if (!this._pollers[file]) {
292 this._pollers[file] = function(curr, prev) {
293 done(null, file);
294 };
295 try {
296 fs.watchFile(file, opts, this._pollers[file]);
297 } catch (err) {
298 return this._handleError(err);
299 }
300 }
301 return this;
302};
303
304// Initialize the actual watch on `watched` files
305Gaze.prototype._initWatched = function(done) {
306 var self = this;
307 var cwd = this.options.cwd || process.cwd();
308 var curWatched = Object.keys(self._watched);
309 helper.forEachSeries(curWatched, function(dir, next) {
310 dir = dir || '';
311 var files = self._watched[dir];
312 // Triggered when a watched dir has an event
313 self._watchDir(dir, function(event, dirpath) {
314 var relDir = cwd === dir ? '.' : path.relative(cwd, dir);
315 relDir = relDir || '';
316
317 fs.readdir(dirpath, function(err, current) {
318 if (err) { return self.emit('error', err); }
319 if (!current) { return; }
320
321 try {
322 // append path.sep to directories so they match previous.
323 current = current.map(function(curPath) {
324 if (fs.existsSync(path.join(dir, curPath)) && fs.statSync(path.join(dir, curPath)).isDirectory()) {
325 return curPath + path.sep;
326 } else {
327 return curPath;
328 }
329 });
330 } catch (err) {
331 // race condition-- sometimes the file no longer exists
332 }
333
334 // Get watched files for this dir
335 var previous = self.relative(relDir);
336
337 // If file was deleted
338 previous.filter(function(file) {
339 return current.indexOf(file) < 0;
340 }).forEach(function(file) {
341 if (!helper.isDir(file)) {
342 var filepath = path.join(dir, file);
343 self.remove(filepath);
344 self.emit('deleted', filepath);
345 }
346 });
347
348 // If file was added
349 current.filter(function(file) {
350 return previous.indexOf(file) < 0;
351 }).forEach(function(file) {
352 // Is it a matching pattern?
353 var relFile = path.join(relDir, file);
354 // Add to watch then emit event
355 self._internalAdd(relFile, function() {
356 self.emit('added', path.join(dir, file));
357 });
358 });
359
360 });
361 });
362
363 // Watch for change/rename events on files
364 files.forEach(function(file) {
365 if (helper.isDir(file)) { return; }
366 self._pollFile(file, function(err, filepath) {
367 // Only emit changed if the file still exists
368 // Prevents changed/deleted duplicate events
369 if (fs.existsSync(filepath)) {
370 self.emit('changed', filepath);
371 }
372 });
373 });
374
375 next();
376 }, function() {
377
378 // Return this instance of Gaze
379 // delay before ready solves a lot of issues
380 setTimeout(function() {
381 self.emit('ready', self);
382 done.call(self, null, self);
383 }, delay + 100);
384
385 });
386};
387
388// If an error, handle it here
389Gaze.prototype._handleError = function(err) {
390 if (err.code === 'EMFILE') {
391 return this.emit('error', new Error('EMFILE: Too many opened files.'));
392 }
393 return this.emit('error', err);
394};