diff --git a/.npmignore b/.npmignore index f16fc41..1b437e8 100644 --- a/.npmignore +++ b/.npmignore @@ -2,3 +2,5 @@ tests/ test/ *.test.js testing/ +example/ +nodeman.json diff --git a/example/example.js b/example/example.js index f761f90..2b1b600 100644 --- a/example/example.js +++ b/example/example.js @@ -1,23 +1,57 @@ import Watcher from '../src/watcher' import onDeath from 'ondeath' +const USE_CUSTOM_HANDLER=true +const DEBOUNCE=0 +const READY_TIMEOUT=null + +; (async () => { let options = { source:'./example/repo/**', ignored:['**/dontwatch.js'], ignoreList:['./example/repo/.gitignore'], - readyTimeout:null} - // let options = {source:'./readme.md'} - // let options = {source:'./example/*'} + debounce: DEBOUNCE, + readyTimeout:READY_TIMEOUT + } let watcher = new Watcher(options) - await watcher.start() - watcher.on('changed', - (change) => { - console.log(`======= file ${change.file} was ${change.type} ==========`) + watcher.on('ready', (state,opts) =>{ + console.log('watched files indexed and ready??',state) + if (opts) console.dir(opts) + }) + + watcher.on('watching', (state, opts) =>{ + console.log('watcher is active and listening for changes?',state) + if (opts) console.dir(opts) + }) + + + await watcher.start( + // {ignored:'**/another'} + ) + + if (USE_CUSTOM_HANDLER) { + watcher.registerHandler( + function handler (type, f) { + this.emit('custom', f, type) + } // end handler + ) + watcher.on('custom', + (file, type) => { + console.log(`custom handler ======= file ${file} was ${type} ==========`) + }) + } + + // default handler event is `changed` + else watcher.on('changed', + (file,type) => { + console.log(`======= file ${file} was ${type} ==========`) }) + + onDeath( () => { console.log('\nHe\'s dead Jim') watcher.remove() diff --git a/package.json b/package.json index c0f0468..c81f380 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,9 @@ { "name": "@uci-utils/watcher", - "version": "0.4.1", + "version": "0.5.4", "description": "File System Watcher Class that emits events", "main": "src/watcher.js", "scripts": { - "example": "node -r esm ./example/example.js", "exampled": "UCI_ENV=dev UCI_LOG_LEVEL=debug ./node_modules/.bin/nodemon -r esm ./example/example.js", "test": "./node_modules/.bin/mocha -r esm --timeout 30000", "testd": "UCI_ENV=dev ./node_modules/.bin/nodemon --exec './node_modules/.bin/mocha -r esm --timeout 30000' || exit 0", @@ -32,7 +31,8 @@ "dependencies": { "@uci-utils/logger": "0.0.18", "@uci-utils/read-lines": "^0.2.2", - "chokidar": "^3.5.1" + "chokidar": "^3.5.1", + "debounce-fn": "^4.0.0" }, "devDependencies": { "chai": "^4.3.3", diff --git a/readme.md b/readme.md index 0440a66..6228699 100644 --- a/readme.md +++ b/readme.md @@ -1,4 +1,80 @@ -### File System Watcher Class -#### a uCOMmandIt Utiltiy Function +# A File System Watcher Class +**a uCOMmandIt Utility Package** -Extends Choikar +## Descripton + +An extended emitter class that includes a fileystem watcher (choikar). + +## Use + +import and call the create function with your options or import the Watcher class extend if desired and instantiate with your options + +call the async start method and you are up and running + +### options + +```javascript +opts = { + source:'./example/repo/**', // what to watch - required!, can be string or array of strings + ignored:['**/dontwatch.js'], // individual globs + ignoreList:['./example/repo/.gitignore'], // files containing newline lists of globs + excludeFrom: ['./example/repo/.gitignore'], // optional alternative ignore list property compatible with rsync + debounce: 0, // default is no debounce + readyTimeout:10000, // default is 10 seconds to index the files to be watched, + delDir: false, // default: ignore directory deletion events + addDir: false, // default: ignore directory addition events + unlinkDir:true // make chokidar emit on deleted directory as well. +} +``` +**plus any options supported by chokidar** +[chokidar options](https://github.com/paulmillr/chokidar#persistence) + +## API + +### methods +``` +registerHandler (func,opts) // can be called at any time but unexpected problems may arise if called when watcher is already async start(opts) // starts the watcher which will initializes the files indexing and set the handler function +stop() // stops +remove() completly removes the current watcher. +async restart(opts,force) // first stops the watcher then calls start, if force is true it will reintialize the watcher as well +getWatcher // returns handle to choikar watcher for choikar methods access +``` +### getters + +watching +ready + +### events + +changed: filepath, type // when a watched file has a change (type will be added,removed,or modified) +error: message,error +watching: state,options // listening for changes +ready: state, options // done indexing files to be watched + +### handler + +#### default +the built in default handler is probably sufficient for most use cases. It emits a 'changed' event with the file or directory name changed and with a type, that being modified, deleted, added. The default handler also emits a 'install' event if the file is package.json in the root of the source is modified (used for restarting), or a warning if it is deleted or added + +```javascript +function _handler (type, f) { + log.debug(`file ${f} was ${type}`) + const fname = path.basename(f) + if ( fname.toLowerCase() === 'package.json' && path.dirname(f)=== this.opts.source.replace(/\/$/, '')) + if (type !=='modified') { + const msg = `a package.json in root of ${this.opts.source} was added or removed` + log.warning(msg) + this.emit('warning',f,msg) + return + } else{ + this.emit('install', f) + } + // user might want to run debounce on the listener for this event + log.debug({file:f, type:type, msg:'file system changed, emitting'}) + this.emit('changed', f,type) +} // end handler +``` + +#### custom + +one can use a custom handler by passing such a function in options or using registerHandler. The handler is passed two arguments the type of the change and the file changed. diff --git a/src/watcher.js b/src/watcher.js index 7b668d8..0bff0bf 100644 --- a/src/watcher.js +++ b/src/watcher.js @@ -3,39 +3,141 @@ import { EventEmitter as Emitter } from 'events' import path from 'path' // third party imports import { watch } from 'chokidar' +import debounce from 'debounce-fn' // UCI imports -import ignores from '@uci-utils/read-lines' +import readIgnoreLists from '@uci-utils/read-lines' import logger from '@uci-utils/logger' let log = {} const READY_TIMEOUT = 10000 //default - class Watcher extends Emitter { constructor(opts={}) { super() log = logger({ package:'@uci-utils/watcher', class:'Watcher', file:'src/watcher.js'}) - opts.unlinkDir = Object.hasOwnProperty(opts.unlinkDir) ? opts.unlinkDir : true + opts.unlinkDir = 'unlinkDir' in opts ? opts.unlinkDir : true // delete file even on by default this.opts = opts this.timeout = process.env.READY_TIMEOUT || opts.readyTimeout || READY_TIMEOUT - this._ignored = [] + this._ignored = opts.ignored ? (Array.isArray(opts.ignored) ? opts.ignored : opts.ignored.split(',')) : [] + this.handler = opts.handler || _handler this._ready=false - this.watching=false + this._watching=false return this } - async init(opts) { + get watching () { + return this._watching + } + + get ready () { + return this._ready + } + + registerHandler(func,opts) { + opts = Object.assign({},this.opts,opts) + if (!this._watcher) return 'failed: watcher not initialized' + if (typeof func ==='function') this.handler = func + else if (func) opts = func + opts = Object.assign({},this.opts,opts) + let handler + if (opts.debounce) + handler = debounce((type, file) => { + log.debug(`waited : ${opts.debounce}ms before calling handler`) + log.info('debounced handler, only last event is emitted') + this.handler.call(this,type,file) + },{wait:opts.debounce}) + else + handler= (type,file) => {this.handler.call(this,type,file)} + + this._watcher.removeAllListeners() + + if (opts.delDir) this._watcher.on('unlinkDir', handler.bind(this, 'dir-deleted')) + if (opts.addDir) this._watcher.on('addDir', handler.bind(this, 'dir-added')) + + this._watcher + .on('add', handler.bind(this, 'added')) + .on('change', handler.bind(this, 'modified')) + .on('unlink', handler.bind(this, 'removed')) + + // reset the error event since it got scrubbed + this._watcher.on('error', error => { + const msg ='chokidar watcher error' + log.error({error:error, msg:msg}) + this.emit('error',msg,error) + }) + } + + async start(opts={}) { + if(this._watching) { + log.warn(`watching aleady running for ${opts.source || this.opts.source}`) + return false + } + if (!this._watcher) opts = await this._init(opts) + if (this._ready) { + this.emit('ready', true, opts) + log.trace({watching:this._watcher.getWatched(),msg:'initial files watched'}) + log.info(`now watching ${opts.source || this.opts.source}`) + if (this.registerHandler(opts)) { + log.fatal('watcher was not initialzed so could not register handler') + return new Error('watcher was not initialzed so could not register handler') + } + this._watching = true + this.emit('watching',true,opts) + } else { + const msg ='watcher is not ready to start, check options and try again' + log.fatal(msg) + this.emit('error',msg) + return new Error('not ready to start check configuration options') + } + } + + stop() { + if(this._watching) { + this._watching = false + this._watcher.close() + this.emit('watching',false) + } + else log.warn('not watching, nothing to stop') + } + + remove() { + this.stop() + this._watcher.removeAllListeners() + delete(this._watcher) + this._ready=false + this.emit('ready',false) + } + + async restart(opts,force) { + if (typeof opts ==='boolean') {force=opts,opts={}} + this.stop() + await this.start(opts) + } + + getWatcher() { + return this._watcher + } + + // private methods + + async _init(opts={}) { + if (!opts.overwrite) { + await this._fetchIgnoreLists(this.opts.excludeFrom) + await this._fetchIgnoreLists(this.opts.ignoreList) + } + await this._fetchIgnoreLists(opts.excludeFrom,opts.overwrite) + await this._fetchIgnoreLists(opts.ignoreList,opts.overwrite) + if (!opts.ignored) opts.ignored = [] + opts.ignored = Array.isArray(opts.ignored) ? opts.ignored : opts.ignored.split(',') + opts.ignored = opts.overwrite ? opts.ignored : [...this._ignored,...opts.ignored] + opts = Object.assign({},this.opts,opts) // now that ingnore arrays are dealt with merge options return new Promise(async (resolve, reject) => { - opts = opts || this.opts log.debug({options:opts, msg:'intializing watch with options'}) - if (opts.excludeFrom) await this.getIgnoreLists(opts.excludeFrom) - if (opts.ignoreList) await this.getIgnoreLists(opts.ignoreList) if (opts.source) { - opts.ignored = opts.ignored ? [...this._ignored,...opts.ignored] : this._ignored - log.debug({ignored:opts.ignored, msg:'all ignores'}) + // create chokidar watcher this._watcher = watch(opts.source,opts) - this._watcher.on('error', error => { + this._watcher.once('error', error => { log.error({error:error, msg:'Watcher error'}) reject({error:error, msg:'Watcher error'}) }) @@ -43,13 +145,12 @@ class Watcher extends Emitter { clearTimeout(readyTimeout) log.info('initial scan sucessful, ready to start') this._ready=true - this.opts = opts // save options - resolve() + resolve(opts) }) log.debug(`initial scanning, timeout in ${this.timeout}ms`) let readyTimeout = setTimeout(() =>{ log.fatal({options:opts, timeout:this.timeout, msg:'Timeout: unable to complete initial scan'}) - reject('timeout during initial scan') + reject('timeout during initial scan, maybe increase ready timeout') },this.timeout) } else { @@ -59,78 +160,34 @@ class Watcher extends Emitter { }) } - async start(opts) { - if(this.watching) { - log.warn(`watching aleady running for ${this.opts.source}`) - return false - } - if (!this._watcher) await this.init(opts) - if (this._ready) { - log.trace({watching:this._watcher.getWatched(),msg:'initial files watched'}) - log.info(`now watching ${this.opts.source}`) - this._watcher.removeAllListeners() // just in case - this.watching = true - // define command listen handler - const handler = - (type, f) => { - log.debug(`file ${f} was ${type}`) - // convert this to a plugin/hook so it's not specific - const fname = path.basename(f) - if ( fname.toLowerCase() === 'package.json') - if (type !=='modified') { - this.emit('error',new Error('package.json was added or removed, ignoring sync and reinstall')) - return - } else{ - this.emit('install', f) - } - // user might want to run debounce on the listener for this event - log.debug({file:f, type:type, msg:'file system changed, emitting'}) - this.emit('changed', {file:f, type:type}) - } // end handler - - this._watcher - .on('add', handler.bind(this, 'added')) - .on('change', handler.bind(this, 'modified')) - .on('unlink', handler.bind(this, 'removed')) - if(this.opts.unlinkDir) this._watcher.on('unlinkDir', handler.bind(this, 'dir-deleted')) - if(this.opts.addDir) this._watcher.on('addDir', handler.bind(this, 'dir-added')) - } else { - log.warn('watcher is not ready to start, check options and try again') - return new Error('not ready to start check configuration') - } - } - - stop() { - if(this.watching) { - this.watching = false - this._watcher.close() - } - else log.warn('not watching, nothing to close') - } - - remove() { - this.stop() - delete(this._watcher) - this._ready=false - } - - async restart(opts) { - this.remove() - await this.start(opts) - } - - async getIgnoreLists(lists) { + async _fetchIgnoreLists(lists,overwrite) { if (typeof lists === 'string') lists=[lists] - let ignored = await ignores(lists) - this._ignored = [...this._ignored,...ignored] + if (!Array.isArray(lists)) return // no lists + let ignored = await readIgnoreLists(lists) + this._ignored = overwrite ? ignored : [...this._ignored,...ignored] } - addIgnore(ignore) { - Array.isArray(ignore) ? this._ignored.push(...ignore) : this._ignored.push(ignore) - } - clearAllIgnore() { this._ignored = [] } -} + +} //end class + +// default handler +function _handler (type, f) { + log.debug(`file ${f} was ${type}`) + const fname = path.basename(f) + if ( fname.toLowerCase() === 'package.json' && path.dirname(f)=== this.opts.source.replace(/\/$/, '')) + if (type !=='modified') { + const msg = `a package.json in root of ${this.opts.source} was added or removed` + log.warning(msg) + this.emit('warning',f,msg) + return + } else{ + this.emit('install', f) + } + // user might want to run debounce on the listener for this event + log.debug({file:f, type:type, msg:'file system changed, emitting'}) + this.emit('changed', f,type) +} // end handler export default Watcher export { Watcher }