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 }