chokidar
Version:
Minimal and efficient cross-platform file watching library
751 lines (750 loc) • 30.2 kB
JavaScript
/*! 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 };