├── .eslintrc ├── .gitignore ├── .npmignore ├── .travis.yml ├── README.md ├── index.js ├── package.json └── test └── index.js /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "node": true 4 | }, 5 | "rules": { 6 | "curly": [2, "multi-line"], 7 | "consistent-return": 0, 8 | "eqeqeq": 0, 9 | "new-cap": 0, 10 | "no-path-concat": 0, 11 | "no-underscore-dangle": 0, 12 | "no-use-before-define": [2, "nofunc"], 13 | "quotes": [2, "single", "avoid-escape"], 14 | "semi": [2, "never"], 15 | "strict": [2, "never"] 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | test/tmp 3 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | test -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 0.10 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # filewatcher 2 | 3 | [![Build Status](https://travis-ci.org/fgnass/filewatcher.png?branch=master)](https://travis-ci.org/fgnass/filewatcher) 4 | 5 | Simple wrapper around `fs.watch` that works around the [various issues](https://github.com/joyent/node/search?q=fs.watch&type=Issues) 6 | you have to deal with when using the Node API directly. 7 | 8 | More precisely filewatcher … 9 | * always reports file names (for all events on all OSes) 10 | * works well with editors that perform atomic writes (save & rename) like Sublime Text or vim 11 | * doesn't fire twice when files are saved 12 | * falls back to `fs.watchFile` when running out of file handles 13 | * has no native dependencies 14 | * uses Node's async APIs under the hood 15 | 16 | This module is used by [node-dev](https://npmjs.org/package/node-dev) 17 | and [instant](https://npmjs.org/package/instant). 18 | 19 | ### Usage 20 | 21 | ```js 22 | var filewatcher = require('filewatcher'); 23 | 24 | var watcher = filewatcher(); 25 | 26 | // watch a file 27 | watcher.add(__filename); 28 | 29 | // ... or a directory 30 | watcher.add(__dirname); 31 | 32 | watcher.on('change', function(file, stat) { 33 | console.log('File modified: %s', file); 34 | if (!stat) console.log('deleted'); 35 | }); 36 | ``` 37 | 38 | To stop watching, you can remove either a single file or all watched files at once: 39 | ```js 40 | watcher.remove(file) 41 | watcher.removeAll() 42 | ``` 43 | 44 | #### Notify users when falling back to polling 45 | 46 | When the process runs out of file handles, _filewatcher_ closes all watchers and transparently switches to `fs.watchFile` polling. You can notify your users by listening to the `fallback` event: 47 | 48 | ```js 49 | watcher.on('fallback', function(limit) { 50 | console.log('Ran out of file handles after watching %s files.', limit); 51 | console.log('Falling back to polling which uses more CPU.'); 52 | console.log('Run ulimit -n 10000 to increase the limit for open files.'); 53 | }); 54 | ``` 55 | 56 | ### Options 57 | 58 | You can pass options to `filewatcher()` in order to tweak its internal settings. These are the defaults: 59 | 60 | ```js 61 | // the default options 62 | var opts = { 63 | forcePolling: false, // try event-based watching first 64 | debounce: 10, // debounce events in non-polling mode by 10ms 65 | interval: 1000, // if we need to poll, do it every 1000ms 66 | persistent: true // don't end the process while files are watched 67 | }; 68 | 69 | var watcher = filewatcher(opts) 70 | ``` 71 | 72 | ### The MIT License (MIT) 73 | 74 | Copyright (c) 2013-2016 Felix Gnass 75 | 76 | Permission is hereby granted, free of charge, to any person obtaining a copy 77 | of this software and associated documentation files (the "Software"), to deal 78 | in the Software without restriction, including without limitation the rights 79 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 80 | copies of the Software, and to permit persons to whom the Software is 81 | furnished to do so, subject to the following conditions: 82 | 83 | The above copyright notice and this permission notice shall be included in 84 | all copies or substantial portions of the Software. 85 | 86 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 87 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 88 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 89 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 90 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 91 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 92 | THE SOFTWARE. 93 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | var fs = require('fs') 2 | var util = require('util') 3 | var debounce = require('debounce') 4 | var events = require('events') 5 | var EventEmitter = events.EventEmitter 6 | 7 | var outOfFileHandles = false 8 | 9 | module.exports = function(opts) { 10 | return new FileWatcher(opts) 11 | } 12 | 13 | function FileWatcher(opts) { 14 | if (!opts) opts = {} 15 | if (opts.debounce === undefined) opts.debounce = 10 16 | if (opts.persistent === undefined) opts.persistent = true 17 | if (!opts.interval) opts.interval = 1000 18 | this.polling = opts.forcePolling 19 | this.opts = opts 20 | this.watchers = {} 21 | } 22 | 23 | util.inherits(FileWatcher, EventEmitter) 24 | 25 | /** 26 | * Start watching the given file. 27 | */ 28 | FileWatcher.prototype.add = function(file) { 29 | var self = this 30 | 31 | // don't add files after we ran out of file handles 32 | if (outOfFileHandles && !this.polling) return 33 | 34 | // ignore files that don't exist or are already watched 35 | if (this.watchers[file]) return 36 | fs.stat(file, function (e, stat) { 37 | if (e) return 38 | 39 | // remember the current mtime 40 | var mtime = stat.mtime 41 | 42 | // callback for both fs.watch and fs.watchFile 43 | function check() { 44 | fs.stat(file, function(e, stat) { 45 | 46 | if (!self.watchers[file]) return 47 | 48 | // close watcher and create a new one to work around fs.watch() bug 49 | // see https://github.com/joyent/node/issues/3172 50 | if (!self.polling) { 51 | self.remove(file) 52 | add(true) 53 | } 54 | 55 | if (!stat) { 56 | self.emit('change', file, { deleted: true }) 57 | } 58 | else if (stat.isDirectory() || stat.mtime > mtime) { 59 | mtime = stat.mtime 60 | self.emit('change', file, stat) 61 | } 62 | }) 63 | } 64 | 65 | function add(silent) { 66 | if (self.polling) { 67 | self.watchers[file] = { close: function() { fs.unwatchFile(file) }} 68 | fs.watchFile(file, self.opts, check) 69 | return 70 | } 71 | 72 | try { 73 | // try using fs.watch ... 74 | self.watchers[file] = fs.watch(file, self.opts, 75 | debounce(check, self.opts.debounce) 76 | ) 77 | } 78 | catch (err) { 79 | if (err.code == 'EMFILE') { 80 | if (self.opts.fallback !== false) { 81 | // emit fallback event if we ran out of file handles 82 | var count = self.poll() 83 | add() 84 | self.emit('fallback', count) 85 | return 86 | } 87 | outOfFileHandles = true 88 | } 89 | if (!silent) self.emit('error', err) 90 | } 91 | } 92 | 93 | add() 94 | }) 95 | } 96 | 97 | /** 98 | * Switch to polling mode. This method is invoked internally if the system 99 | * runs out of file handles. 100 | */ 101 | FileWatcher.prototype.poll = function() { 102 | if (this.polling) return 0 103 | this.polling = true 104 | var watched = Object.keys(this.watchers) 105 | this.removeAll() 106 | watched.forEach(this.add, this) 107 | return watched.length 108 | } 109 | 110 | /** 111 | * Lists all watched files. 112 | */ 113 | FileWatcher.prototype.list = function() { 114 | return Object.keys(this.watchers) 115 | } 116 | 117 | /** 118 | * Stop watching the given file. 119 | */ 120 | FileWatcher.prototype.remove = function(file) { 121 | var watcher = this.watchers[file] 122 | if (!watcher) return 123 | delete this.watchers[file] 124 | watcher.close() 125 | } 126 | 127 | /** 128 | * Stop watching all currently watched files. 129 | */ 130 | FileWatcher.prototype.removeAll = function() { 131 | this.list().forEach(this.remove, this) 132 | } 133 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "filewatcher", 3 | "version": "3.0.0", 4 | "description": "Wrapper around fs.watch with fallback to fs.watchFile", 5 | "author": "Felix Gnass", 6 | "keywords": [ 7 | "fs", 8 | "file", 9 | "watch", 10 | "watchFile" 11 | ], 12 | "repository": { 13 | "type": "git", 14 | "url": "http://github.com/fgnass/filewatcher.git" 15 | }, 16 | "license": "MIT", 17 | "main": "index.js", 18 | "scripts": { 19 | "test": "ulimit -n 100 && ULIMIT=`ulimit -n` node test" 20 | }, 21 | "devDependencies": { 22 | "rimraf": "~2.2.2", 23 | "tap": "^0.5.0" 24 | }, 25 | "dependencies": { 26 | "debounce": "^1.0.0" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | var fs = require('fs') 2 | var path = require('path') 3 | 4 | var filewatcher = require('..') 5 | var rimraf = require('rimraf') 6 | var tap = require('tap') 7 | 8 | var dir = path.join(__dirname, 'tmp') 9 | 10 | function suite(polling) { 11 | 12 | var i = 0 13 | function createFile() { 14 | var n = path.join(dir, 'tmp00' + (i++)) 15 | fs.writeFileSync(n, Date.now()) 16 | return n 17 | } 18 | 19 | function createDir() { 20 | var d = path.join(dir, 'dir00' + (i++)) 21 | var f = path.join(d, 'file') 22 | fs.mkdirSync(d) 23 | fs.writeFileSync(f, Date.now()) 24 | return f 25 | } 26 | 27 | function touch(f) { 28 | setTimeout(function() { fs.writeFileSync(f, Date.now()) }, 1000) 29 | } 30 | 31 | function del(f) { 32 | setTimeout(function() { fs.unlinkSync(f) }, 1000) 33 | } 34 | 35 | var w // the watcher 36 | 37 | function test(name, conf, cb) { 38 | w = filewatcher({ forcePolling: polling }) 39 | name += polling ? ' (fs.watchFile)' : ' (fs.watch)' 40 | return tap 41 | .test(name, conf, cb) 42 | .once('end', function() { 43 | w.removeAll() 44 | w.removeAllListeners() 45 | }) 46 | } 47 | 48 | test('prep', function(t) { 49 | rimraf.sync(dir) 50 | fs.mkdirSync(dir) 51 | t.end() 52 | }) 53 | 54 | test('change', function(t) { 55 | t.plan(2) 56 | w.on('change', function(file, stat) { 57 | t.equal(file, f) 58 | t.ok(stat.mtime > 0, 'mtime > 0') 59 | }) 60 | w.on('error', function(err) { t.fail(err) }) 61 | 62 | var f = createFile() 63 | w.add(f) 64 | 65 | touch(f) 66 | }) 67 | 68 | test('delete', function(t) { 69 | t.plan(2) 70 | var f = createFile() 71 | w.on('change', function(file, stat) { 72 | t.equal(file, f) 73 | t.ok(stat.deleted, 'stat.deleted') 74 | }) 75 | w.add(f) 76 | del(f) 77 | }) 78 | 79 | test('add to dir', function(t) { 80 | t.plan(1) 81 | w.on('change', function(file, stat) { 82 | t.equal(file, dir) 83 | }) 84 | w.add(dir) 85 | setTimeout(createFile, 1000) 86 | }) 87 | 88 | test('fire more than once', function(t) { 89 | t.plan(2) 90 | var f = createFile() 91 | w.on('change', function(file, stat) { 92 | t.equal(file, f) 93 | if (!stat.deleted) del(f) 94 | }) 95 | w.add(f) 96 | touch(f) 97 | }) 98 | 99 | test('remove listener', { timeout: 4000 }, function(t) { 100 | var f = createFile() 101 | w.on('change', function(file, stat) { 102 | t.equal(file, f) 103 | t.notOk(stat.deleted, 'not deleted') 104 | w.remove(file) 105 | del(f) 106 | setTimeout(function() { t.end() }, 1000) 107 | }) 108 | w.add(f) 109 | touch(f) 110 | }) 111 | 112 | if (!polling && process.platform != 'linux') { 113 | test('fallback', function(t) { 114 | var ulimit = parseInt(process.env.ULIMIT) 115 | if (!ulimit) { 116 | console.log('Set the ULIMIT env var to `ulimit -n` to test the fallback') 117 | return t.end() 118 | } 119 | if (ulimit > 4000) { 120 | console.log('reduce ulimit < 4000 to test the polling fallback') 121 | return t.end() 122 | } 123 | 124 | var files = new Array(ulimit).join().split(',').map(createDir) 125 | var last = files[files.length-1] 126 | 127 | w.on('fallback', function(limit) { 128 | t.ok(limit > 0, 'fallback') 129 | touch(last) 130 | }) 131 | w.on('change', function(file) { 132 | t.equal(file, last) 133 | }) 134 | w.on('error', function(err) { 135 | t.fail(err) 136 | }) 137 | 138 | t.plan(2) 139 | files.forEach(w.add, w) 140 | }) 141 | } 142 | } 143 | 144 | suite() 145 | suite(true) 146 | 147 | process.on('exit', function() { 148 | rimraf.sync(dir) 149 | }) 150 | --------------------------------------------------------------------------------