// native imports 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 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 = '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 = opts.ignored ? (Array.isArray(opts.ignored) ? opts.ignored : opts.ignored.split(',')) : [] this.handler = opts.handler || _handler this._ready = false this._watching = false // .goutputstream files arise during atomic writes, ignore them by default this.ignore_goutputstream = opts.ignore_goutputstream === false ? false : true return this } 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')) // these are watched by default, one can take action or not in the handler 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 initialized so could not register handler') return new Error('watcher was not initialized 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() { if (this._watcher) { this.stop() this._watcher.removeAllListeners() delete (this._watcher) } else log.warn('no watcher started nothing to remove') 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 // set up chokidar 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] if (this.ignore_goutputstream) opts.ignored.push('**/.goutputstream*') opts = Object.assign({}, this.opts, opts) // now that ignore arrays are dealt with merge options return new Promise(async (resolve, reject) => { log.debug({ options: opts, msg: 'initializing watch with options' }) if (opts.source) { // create chokidar watcher this._watcher = watch(opts.source, opts) this._watcher.once('error', error => { log.error({ error: error, msg: 'Watcher error' }) reject({ error: error, msg: 'Watcher error' }) }) this._watcher.once('ready', () => { clearTimeout(readyTimeout) log.info('initial scan sucessful, ready to start') this._ready = true 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, maybe increase ready timeout') }, this.timeout) } else { log.fatal('MUST provide a source directory(s) option to watch') reject('watching - no source provided') } }) } async _fetchIgnoreLists(lists, overwrite) { if (typeof lists === 'string') lists = [lists] if (!Array.isArray(lists)) return // no lists let ignored = await readIgnoreLists(lists) this._ignored = overwrite ? ignored : [...this._ignored, ...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 }