UNPKG

chokidar

Version:

Minimal and efficient cross-platform file watching library

751 lines (750 loc) • 30.2 kB
/*! chokidar - MIT License (c) 2012 Paul Miller (paulmillr.com) */ import { EventEmitter } from 'node:events'; import { realpathSync } from 'node:fs'; import { readdir } from 'node:fs/promises'; import * as sp from 'node:path'; import { readdirp, ReaddirpStream } from 'readdirp'; import { selectBackend } from './backend.js'; import { EventPolicy } from './policy.js'; import { ObservationEngine } from './reconcile.js'; import { cloneOwnedMatcher, compileMatchers, EVENTS as EV, isIBMi, isMatcherObject, isMissingError, isPermissionError, isSameOrInside, isStrictlyInside, isWindows, logicalPathKey, normalizeMatcher, normalizePath, systemScheduler, WatchHelper, } from './runtime.js'; import { LifecycleScope, ReconciliationQueue, TreeState } from './tree.js'; const DOT_RE = /\..*\.(sw[px])$|~$|\.subl.*\.tmp/; const WATCH_BACKENDS = new Set(['auto', 'native', 'native-recursive', 'polling']); const DEF_AWF = Object.freeze({ stabilityThreshold: 2000, pollInterval: 100 }); function* ancestorKeys(path) { let current = sp.resolve(path); for (let parent = sp.dirname(current); parent !== current; parent = sp.dirname(parent)) { yield logicalPathKey(parent); current = parent; } } function arrify(item) { return Array.isArray(item) ? item : [item]; } function unifyPaths(paths_) { const paths = arrify(paths_).flat(); if (!paths.every((p) => typeof p === 'string')) { throw new TypeError(`Non-string provided as watch path: ${paths}`); } return paths.map(normalizePath); } function getAbsolutePath(path, cwd) { if (sp.isAbsolute(path)) { return path; } return sp.join(cwd, path); } function validatePositiveFinite(name, value) { if (!Number.isFinite(value) || value <= 0) { throw new TypeError(`${name} must be a finite number greater than zero`); } } function validateNonNegativeFinite(name, value) { if (!Number.isFinite(value) || value < 0) { throw new TypeError(`${name} must be a finite non-negative number`); } } function validateOptions(opts) { validatePositiveFinite('pollingInterval', opts.pollingInterval); validatePositiveFinite('pollingBinaryInterval', opts.pollingBinaryInterval); if (typeof opts.atomic === 'number') validateNonNegativeFinite('atomic', opts.atomic); else if (typeof opts.atomic !== 'boolean') { throw new TypeError('atomic must be a boolean or finite non-negative number'); } if (opts.depth !== undefined && (!Number.isSafeInteger(opts.depth) || opts.depth < 0)) { throw new TypeError('depth must be a non-negative safe integer'); } if (opts.awaitWriteFinish) { validatePositiveFinite('awaitWriteFinish.pollInterval', opts.awaitWriteFinish.pollInterval); validateNonNegativeFinite('awaitWriteFinish.stabilityThreshold', opts.awaitWriteFinish.stabilityThreshold); } } /** * Watches files & directories for changes. Emitted events: * `add`, `addDir`, `change`, `unlink`, `unlinkDir`, `all`, `error` * * new FSWatcher() * .add(directories) * .on('add', path => log('File', path, 'was added')) */ export class FSWatcher extends EventEmitter { options; /** @internal */ lifecycle; /** @internal */ tree; /** @internal */ reconciliation; /** @internal */ events; /** @internal */ scheduler; /** @internal */ handler; /** @internal */ readyEmitted; /** @internal */ streams; get closed() { return this.lifecycle !== undefined && this.lifecycle.state !== 'OPEN'; } #ignoredPaths; #pendingAdds; #pathMutation; #pathBarriers; #closePromise; #userIgnored; #realDirs; #roots; #rootAncestors; #unwatchIgnored; #readyPending; #readyScheduled; // Not indenting methods for history sake; for now. constructor(_opts = {}, scheduler = systemScheduler) { super(); if (_opts.backend !== undefined && !WATCH_BACKENDS.has(_opts.backend)) { throw new TypeError('backend must be auto, native, native-recursive, or polling'); } this.#ignoredPaths = new Set(); this.streams = new Set(); this.#pendingAdds = new Map(); this.#pathMutation = 0; this.#pathBarriers = new Map(); this.#realDirs = new Map(); this.#roots = new Set(); this.#rootAncestors = new Map(); this.readyEmitted = false; this.#readyPending = false; this.#readyScheduled = false; this.scheduler = scheduler; const awf = _opts.awaitWriteFinish; const opts = { ..._opts, // Defaults persistent: _opts.persistent ?? true, ignoreInitial: _opts.ignoreInitial ?? false, ignorePermissionErrors: _opts.ignorePermissionErrors ?? false, pollingInterval: _opts.pollingInterval ?? _opts.interval ?? 100, pollingBinaryInterval: _opts.pollingBinaryInterval ?? _opts.binaryInterval ?? 300, interval: 100, binaryInterval: 300, followSymlinks: _opts.followSymlinks ?? true, backend: _opts.backend ?? 'auto', usePolling: _opts.usePolling ?? false, // `undefined` already means unlimited traversal. Preserve the common // `depth: Infinity` spelling without forcing the per-directory backend. depth: _opts.depth === Number.POSITIVE_INFINITY ? undefined : _opts.depth, backendStrategy: 'native-per-directory', backendCapabilities: undefined, // useAsync: false, atomic: _opts.atomic ?? true, // Change format ignored: Object.freeze(_opts.ignored ? arrify(_opts.ignored).map(cloneOwnedMatcher) : []), awaitWriteFinish: awf === true ? DEF_AWF : typeof awf === 'object' ? Object.freeze({ ...DEF_AWF, ...awf }) : false, }; // Always default to polling on IBM i because fs.watch() is not available on IBM i. if (_opts.usePolling === true || isIBMi) opts.backend = 'polling'; // Global override. Useful for developers, who need to force polling for all // instances of chokidar, regardless of usage / dependency depth const envPoll = process.env.CHOKIDAR_USEPOLLING; if (envPoll !== undefined) { const envLower = envPoll.toLowerCase(); const envPolling = envLower === 'false' || envLower === '0' ? false : envLower === 'true' || envLower === '1' ? true : !!envLower; if (envPolling) opts.backend = 'polling'; else if (opts.backend === 'polling') opts.backend = 'auto'; } const envInterval = process.env.CHOKIDAR_INTERVAL; if (envInterval !== undefined) opts.pollingInterval = Number(envInterval); // Preserve resolved values for callers that still read the deprecated fields. opts.interval = opts.pollingInterval; opts.binaryInterval = opts.pollingBinaryInterval; opts.usePolling = opts.backend === 'polling'; opts.backendCapabilities = selectBackend(opts); opts.backendStrategy = opts.backendCapabilities.kind; // Editor atomic write normalization is enabled by default only with fs.watch. // Inspect the raw option so the merged default cannot hide an implicit choice. if (_opts.atomic === undefined) opts.atomic = !opts.usePolling; validateOptions(opts); this.options = opts; this.lifecycle = new LifecycleScope(() => { if (this.#readyPending) this.queueReady(); }, (error) => { if (!this.closed) this.handleError(error instanceof Error ? error : new Error(String(error))); }); this.tree = new TreeState(() => !this.options.backendCapabilities.polling); this.reconciliation = new ReconciliationQueue(this.lifecycle); this.events = new EventPolicy({ options: this.options, scheduler: this.scheduler, lifecycle: this.lifecycle, isClosed: () => this.closed, capturePathGeneration: () => this.capturePathGeneration(), isPathGenerationActive: (path, generation) => this.isPathGenerationActive(path, generation), isReady: () => this.readyEmitted, remove: (directory, item) => this.removePath(directory, item), handleError: (error) => this.handleError(error), publish: (event, args) => this.emitWithAll(event, args), }); this.handler = new ObservationEngine(this); // You’re frozen when your heart’s not open. Object.freeze(opts); } addIgnoredPath(matcher) { matcher = this.ignoredMatcher(matcher); if (isMatcherObject(matcher)) { // return early if we already have a deeply equal matcher object for (const ignored of this.#ignoredPaths) { if (isMatcherObject(ignored) && ignored.path === matcher.path && ignored.recursive === matcher.recursive) { return; } } } this.#ignoredPaths.add(matcher); this.#unwatchIgnored = undefined; } removeIgnoredPath(matcher) { matcher = this.ignoredMatcher(matcher); this.#ignoredPaths.delete(matcher); // now find any matcher objects with the matcher as path if (typeof matcher === 'string') { for (const ignored of this.#ignoredPaths) { // TODO (43081j): make this more efficient. // probably just make a `this._ignoredDirectories` or some // such thing. if (isMatcherObject(ignored) && ignored.path === matcher) { this.#ignoredPaths.delete(ignored); } } } this.#unwatchIgnored = undefined; } ignoredMatcher(matcher) { if (typeof matcher === 'string') return logicalPathKey(matcher); if (isMatcherObject(matcher)) { return { path: logicalPathKey(matcher.path), recursive: matcher.recursive }; } return matcher; } /** @internal */ capturePathGeneration() { return this.#pathMutation; } invalidatePath(path) { this.#pathBarriers.set(logicalPathKey(path), ++this.#pathMutation); } /** @internal */ isPathGenerationActive(path, generation) { if (this.lifecycle.state !== 'OPEN') return false; if (this.#pathBarriers.size === 0) return true; const logicalKey = logicalPathKey(path); for (const [barrier, barrierGeneration] of this.#pathBarriers) { if (barrierGeneration <= generation) continue; if (isSameOrInside(barrier, logicalKey)) return false; } return true; } queueReady() { if (this.closed || this.readyEmitted) return; this.#readyPending = true; if (this.lifecycle.tasks.size > 0 || this.#readyScheduled) return; this.#readyScheduled = true; process.nextTick(() => { this.#readyScheduled = false; if (this.closed || this.readyEmitted || this.lifecycle.tasks.size > 0) return; this.#readyPending = false; this.readyEmitted = true; this.emit(EV.READY); }); } // Public methods /** * Adds paths to be watched on an existing FSWatcher instance. * @param paths_ file or file list */ add(paths_) { if (this.closed) { throw new Error('Cannot add paths after FSWatcher.close() has been called'); } const { cwd } = this.options; let paths = unifyPaths(paths_); if (cwd) { paths = paths.map((path) => { const absPath = getAbsolutePath(path, cwd); // Check `path` instead of `absPath` because the cwd portion can't be a glob return absPath; }); } paths.forEach((path) => { this.removeIgnoredPath(path); this.addRootAncestors(path); }); if (!this.readyEmitted) this.#readyPending = true; const addTask = Promise.all(paths.map(async (path) => { const key = logicalPathKey(path); if (this.lifecycle.closers.has(key) || this.#pendingAdds.has(key)) return; const pendingToken = Symbol(key); const pathGeneration = this.capturePathGeneration(); this.#pendingAdds.set(key, pendingToken); try { await this.handler.addRoot(path, true, pathGeneration); } finally { if (this.#pendingAdds.get(key) === pendingToken) this.#pendingAdds.delete(key); } })); this.lifecycle.track(addTask); if (!this.readyEmitted) this.queueReady(); return this; } /** * Close watchers or start ignoring events from specified paths. */ unwatch(paths_) { if (this.closed) return this; let paths = unifyPaths(paths_); const { cwd } = this.options; if (cwd) paths = paths.map((path) => getAbsolutePath(path, cwd)); paths.forEach((path) => { const key = logicalPathKey(path); const isDirectory = this.tree.watched.has(key); this.invalidatePath(key); this.removeRootAncestors(key); this.#pendingAdds.delete(key); this.events.cancelPath(key); this.closePath(key, isDirectory); this.addIgnoredPath(key); if (isDirectory) { this.addIgnoredPath({ path: key, recursive: true, }); } this.#unwatchIgnored = undefined; }); return this; } /** * Close watchers and remove all listeners from watched paths. */ close() { if (this.#closePromise) { return this.#closePromise; } const closers = this.lifecycle.beginClose(); // Memory management. this.removeAllListeners(); this.events.close(); this.#pendingAdds.clear(); this.#roots.clear(); this.#rootAncestors.clear(); this.streams.forEach((stream) => stream.destroy()); this.#userIgnored = undefined; this.#unwatchIgnored = undefined; this.readyEmitted = false; this.tree.dispose(); this.#pathBarriers.clear(); this.streams.clear(); this.reconciliation.clear(); this.#realDirs.clear(); this.#closePromise = (async () => { const closerResults = await Promise.allSettled(closers); await this.lifecycle.drain(); this.lifecycle.finishClose(); const failed = closerResults.find((result) => result.status === 'rejected'); if (failed) throw failed.reason; })(); return this.#closePromise; } /** * Expose list of watched paths. The result has a null prototype, so a * directory named `__proto__` is an ordinary own key. */ getWatched() { const watchList = Object.create(null); this.tree.watched.forEach((entry) => { const dir = entry.path; const key = this.options.cwd ? sp.relative(this.options.cwd, dir) : dir; const index = key || '.'; watchList[index] = entry.getChildren().sort(); }); return watchList; } /** @internal */ emitRaw(...args) { if (!this.closed) this.emit(EV.RAW, ...args); } emitWithAll(event, args) { this.emit(event, ...args); if (event !== EV.ERROR) this.emit(EV.ALL, event, ...args); } // Common helpers // -------------- /** * Normalize and emit events. * Calling emitEvent DOES NOT MEAN emit() would be called! * @param event Type of event * @param path File or directory path * @param stats arguments to be passed with event * @internal */ async emitEvent(event, path, stats) { if (this.closed) return; await this.events.emit(event, path, stats); } /** * Common handler for backend and reconciliation failures. * @internal */ handleError(error) { if (this.closed) return; const normalized = error instanceof Error ? error : new Error(String(error)); if (!isMissingError(normalized) && (!this.options.ignorePermissionErrors || !isPermissionError(normalized))) { this.emit(EV.ERROR, normalized); } } /** * Determines whether user has asked to ignore this path. * @internal */ isIgnored(path, stats) { if (this.options.atomic && DOT_RE.test(path)) return true; if (!this.#userIgnored) { const { cwd } = this.options; const ign = this.options.ignored; const ignored = (ign || []).map((matcher) => normalizeMatcher(matcher, cwd)); const direct = compileMatchers(ignored); const pathMatchers = ignored.filter((matcher) => typeof matcher === 'string' || isMatcherObject(matcher)); const canonical = compileMatchers(pathMatchers); this.#userIgnored = (candidate, candidateStats) => { if (direct(candidate, candidateStats)) return true; if (isWindows || pathMatchers.length === 0) return false; // macOS commonly exposes /var through the /private/var symlink. Resolve // the containing directory (memoized per directory) instead of the // path itself, so files that no longer exist still project correctly. const absoluteCandidate = sp.resolve(candidate); let realPath; if (candidateStats?.isDirectory()) { realPath = this.realDirectory(absoluteCandidate); } else { const realDir = this.realDirectory(sp.dirname(absoluteCandidate)); if (realDir !== undefined) realPath = sp.join(realDir, sp.basename(absoluteCandidate)); } return (realPath !== undefined && realPath !== absoluteCandidate && canonical(realPath, candidateStats)); }; } // Directories above a watched path stay watchable, so a missing path can be // observed through its parent (#1374). Their other contents are still filtered. if (this.#userIgnored(path, stats) && !this.isRootAncestor(path, stats)) return true; if (this.#ignoredPaths.size === 0) return false; if (!this.#unwatchIgnored) { this.#unwatchIgnored = compileMatchers([...this.#ignoredPaths]); } return this.#unwatchIgnored(logicalPathKey(path), stats); } isRootAncestor(path, stats) { // Regular files are never ancestors; skip the resolve on the hot scan path. if (stats?.isFile()) return false; return this.#rootAncestors.has(logicalPathKey(path)); } /** Records every directory above a requested root; refcounted across roots. */ addRootAncestors(path) { const key = logicalPathKey(path); if (this.#roots.has(key)) return; this.#roots.add(key); for (const ancestor of ancestorKeys(path)) { this.#rootAncestors.set(ancestor, (this.#rootAncestors.get(ancestor) ?? 0) + 1); } } removeRootAncestors(path) { if (!this.#roots.delete(logicalPathKey(path))) return; for (const ancestor of ancestorKeys(path)) { const count = (this.#rootAncestors.get(ancestor) ?? 1) - 1; if (count > 0) this.#rootAncestors.set(ancestor, count); else this.#rootAncestors.delete(ancestor); } } realDirectory(directory) { const key = logicalPathKey(directory); if (this.#realDirs.has(key)) return this.#realDirs.get(key); let realPath; try { realPath = realpathSync.native(directory); } catch { } this.#realDirs.set(key, realPath); return realPath; } /** @internal */ isUnwatched(path) { if (this.#ignoredPaths.size === 0) return false; if (!this.#unwatchIgnored) { this.#unwatchIgnored = compileMatchers([...this.#ignoredPaths]); } return this.#unwatchIgnored(logicalPathKey(path)); } /** * Provides a set of common helpers and properties relating to symlink handling. * @param path file or directory pattern being watched * @internal */ createHelper(path) { return new WatchHelper(path, this.options.followSymlinks, { capturePathGeneration: () => this.capturePathGeneration(), isntIgnored: (candidate, stats) => !this.isIgnored(candidate, stats), }); } removeTreeItem(directory, item) { const entry = this.tree.peekDirectory(directory); if (!entry?.remove(item)) return; const path = entry.path; const generation = this.lifecycle.generation; this.lifecycle.track((async () => { try { await readdir(path); } catch (error) { // Only a missing directory is gone; an unreadable one still exists. if (isMissingError(error) && this.lifecycle.isActive(generation)) { this.removePath(sp.dirname(path), sp.basename(path)); } } })()); } // File helpers // ------------ /** * Handles emitting unlink events for * files and directories, and via recursion, for * files and directories within directories that are unlinked * @param directory within which the following item is located * @param item base path of item/directory * @internal */ removePath(directory, item, isDirectory) { // if what is being deleted is a directory, get that directory's paths // for recursive deleting and cleaning of watched object // if it is not a directory, nestedDirectoryChildren will be empty array const path = sp.join(directory, item); const logicalKey = logicalPathKey(path); isDirectory = isDirectory != null ? isDirectory : this.tree.watched.has(logicalKey); // Removal is idempotent: a second caller finds nothing tracked and emits // nothing, while a path re-added in between is truthfully removed again. // Recursively remove children directories / files. const nestedDirectoryChildren = this.tree.peekDirectory(path)?.getChildren() ?? []; for (const nested of nestedDirectoryChildren) this.removePath(path, nested); // Check if item was on the watched list and remove it const wasTracked = this.tree.peekDirectory(directory)?.has(item) ?? false; const hadClosers = this.lifecycle.closers.has(logicalKey); this.removeTreeItem(directory, item); // Fixes issue #1042 -> Relative paths were detected and added as symlinks // (https://github.com/paulmillr/chokidar/blob/e1753ddbc9571bdc33b4a4af172d52cb6e611c10/lib/nodefs-handler.js#L612), // but never removed from the map in case the path was deleted. // This leads to an incorrect state if the path was recreated: // https://github.com/paulmillr/chokidar/blob/e1753ddbc9571bdc33b4a4af172d52cb6e611c10/lib/nodefs-handler.js#L553 if (this.tree.symlinkPaths.has(logicalKey)) { this.tree.symlinkPaths.delete(logicalKey); } // If we wait for this file to be fully written, cancel the wait. const pendingWrite = this.events.pendingWrites.get(logicalKey); let suppressEvent = false; if (this.options.awaitWriteFinish && pendingWrite) { const event = pendingWrite.cancelWait(); suppressEvent = event === EV.ADD; } // Drop the entry if what got removed was a watched directory this.tree.watched.delete(logicalKey); this.tree.observed.delete(logicalKey); const eventName = isDirectory ? EV.UNLINK_DIR : EV.UNLINK; if (wasTracked && !suppressEvent && !this.isIgnored(path)) this.emitEvent(eventName, path); // Avoid conflicts if we later create another file with the same name this.closePath(path); // A requested root that disappears is awaited from its nearest existing ancestor. if ((wasTracked || hadClosers) && this.#roots.has(logicalKey)) this.rewatchMissingPath(path); } /** * Replaces every watch keyed at `path` with a wait from its nearest existing * ancestor, so the path is reported again when it reappears. * @internal */ rewatchMissingPath(path) { if (this.closed || this.isUnwatched(path)) return; this.closePath(path); this.lifecycle.track(this.handler.addRoot(path, false, this.capturePathGeneration())); } /** * Closes all watchers for a path * @internal */ closePath(path, recursive = false) { const logicalKey = logicalPathKey(path); const contains = (candidate) => { if (candidate === logicalKey) return true; if (!recursive) return false; return isStrictlyInside(logicalKey, candidate); }; // Snapshot closers: closing one may register or drop others. [...this.lifecycle.closers.keys()].filter(contains).forEach((key) => this.closeFile(key)); // Resolutions at or below a closed directory may be stale. Only directories // are cached, so file closes stay O(1). Deleting the current key while // iterating a Map is safe. if (this.#realDirs.delete(logicalKey) || recursive) { const prefix = logicalKey.endsWith('/') ? logicalKey : `${logicalKey}/`; for (const key of this.#realDirs.keys()) { if (key.startsWith(prefix)) this.#realDirs.delete(key); } } if (recursive) { for (const [key, entry] of this.tree.watched) { if (!contains(key)) continue; entry.dispose(); this.tree.watched.delete(key); } for (const key of this.tree.observed.keys()) { if (contains(key)) this.tree.observed.delete(key); } for (const key of this.tree.symlinkPaths.keys()) { if (contains(key)) this.tree.symlinkPaths.delete(key); } this.events.cancelWhere(contains); this.reconciliation.forgetPending((scope, candidate) => contains(scope) || contains(candidate)); for (const key of this.#pendingAdds.keys()) { if (contains(key)) this.#pendingAdds.delete(key); } } const dir = sp.dirname(logicalKey); this.removeTreeItem(dir, sp.basename(logicalKey)); } /** * Closes only file-specific watchers */ closeFile(path) { const key = logicalPathKey(path); const closers = this.lifecycle.takeClosers(key); if (closers.length === 0) return; closers.forEach((closer) => { try { const result = closer(); if (result instanceof Promise) this.lifecycle.track(result); } catch (error) { this.lifecycle.track(Promise.reject(error)); } }); } /** @internal */ addPathCloser(path, closer) { if (!closer) return; if (this.closed || this.isUnwatched(path)) { try { const result = closer(); if (result instanceof Promise) this.lifecycle.track(result); } catch (error) { this.lifecycle.track(Promise.reject(error)); } return; } const key = logicalPathKey(path); this.lifecycle.addCloser(key, closer); } /** @internal */ createScanStream(root, opts) { if (this.closed) return; const options = { type: EV.ALL, alwaysStat: true, lstat: true, depth: 0, ...opts }; const stream = readdirp(root, options); this.streams.add(stream); const finalize = () => this.streams.delete(stream); stream.once('close', finalize); stream.once('end', finalize); stream.once(EV.ERROR, finalize); return stream; } } /** * Instantiates watcher with paths to be tracked. * @param paths file / directory paths * @param options opts, such as `atomic`, `awaitWriteFinish`, `ignored`, and others * @returns an instance of FSWatcher for chaining. * @example * const watcher = watch('.').on('all', (event, path) => { console.log(event, path); }); * watch('.', { atomic: true, awaitWriteFinish: true, ignored: (f, stats) => stats?.isFile() && !f.endsWith('.js') }) */ export function watch(paths, options = {}) { const watcher = new FSWatcher(options); watcher.add(paths); return watcher; } export default { watch: watch, FSWatcher: FSWatcher };