chokidar
Version:
Minimal and efficient cross-platform file watching library
329 lines (328 loc) • 11.5 kB
JavaScript
import * as sp from 'node:path';
import { EVENTS, isSameOrInside, logicalPathKey, statIdentity, } from './runtime.js';
/**
* Owns every asynchronous task and backend subscription created by one public
* watcher. It deliberately knows nothing about paths, events, or backends.
*/
export class LifecycleScope {
state = 'OPEN';
generation = 0;
abortController = new AbortController();
tasks = new Set();
closers = new Map();
onTaskSettled;
onTaskError;
constructor(onTaskSettled, onTaskError) {
this.onTaskSettled = onTaskSettled;
this.onTaskError = onTaskError;
}
isActive(generation) {
return this.state === 'OPEN' && this.generation === generation;
}
track(task) {
const tracked = Promise.resolve(task).finally(() => {
this.tasks.delete(tracked);
this.onTaskSettled();
});
this.tasks.add(tracked);
void tracked.catch(this.onTaskError);
return tracked;
}
addCloser(key, closer) {
const existing = this.closers.get(key);
if (existing)
existing.push(closer);
else
this.closers.set(key, [closer]);
}
takeClosers(key) {
const closers = this.closers.get(key) ?? [];
this.closers.delete(key);
return closers;
}
beginClose() {
if (this.state !== 'OPEN')
return [];
this.state = 'CLOSING';
this.generation += 1;
this.abortController.abort();
const closing = [];
this.closers.forEach((closers) => {
closers.forEach((closer) => {
try {
closing.push(Promise.resolve(closer()));
}
catch (error) {
closing.push(Promise.reject(error));
}
});
});
this.closers.clear();
return closing;
}
async drain() {
while (this.tasks.size > 0) {
await Promise.allSettled([...this.tasks]);
}
}
finishClose() {
this.state = 'CLOSED';
}
}
function IGNORE_RECONCILIATION_ERROR() { }
export function resolveRecursiveCandidate(root, relativePath) {
if (sp.isAbsolute(relativePath))
return;
const absoluteRoot = sp.resolve(root);
const absoluteCandidate = sp.resolve(absoluteRoot, relativePath);
if (!isSameOrInside(absoluteRoot, absoluteCandidate))
return;
return sp.join(root, relativePath);
}
/** Serializes commits per root and coalesces redundant candidate invalidations. */
export class ReconciliationQueue {
queues = new Map();
pending = new Map();
lifecycle;
constructor(lifecycle) {
this.lifecycle = lifecycle;
}
enqueue(scope, work, coalescePath = scope) {
const key = logicalPathKey(scope);
const pendingKey = coalescePath === false ? undefined : `${key}\0${logicalPathKey(coalescePath)}`;
const existing = pendingKey ? this.pending.get(pendingKey) : undefined;
if (existing) {
existing.work = work;
existing.replay = true;
return existing.promise;
}
const generation = this.lifecycle.generation;
const reconciliation = {
promise: Promise.resolve(),
work,
replay: false,
};
const previous = this.queues.get(key) ?? Promise.resolve();
const task = previous
.catch(IGNORE_RECONCILIATION_ERROR)
.then(async () => {
do {
const currentWork = reconciliation.work;
reconciliation.replay = false;
if (!this.lifecycle.isActive(generation))
return;
await currentWork();
} while (reconciliation.replay);
})
.finally(() => {
if (this.queues.get(key) === task)
this.queues.delete(key);
if (pendingKey && this.pending.get(pendingKey) === reconciliation) {
this.pending.delete(pendingKey);
}
});
this.queues.set(key, task);
const tracked = this.lifecycle.track(task);
reconciliation.promise = tracked;
if (pendingKey)
this.pending.set(pendingKey, reconciliation);
return tracked;
}
forgetPending(predicate) {
for (const key of this.pending.keys()) {
const [scope, candidate] = key.split('\0', 2);
if (predicate(scope, candidate))
this.pending.delete(key);
}
}
clear() {
this.queues.clear();
this.pending.clear();
}
}
const CURRENT = '.';
const PARENT = '..';
const RECURSIVE_CREATE_BURST_WINDOW = 25;
const RECURSIVE_WRITE_BURST_WINDOW = 10;
const INITIAL_OBSERVATION_FACT = Object.freeze({
size: 0,
mtimeMs: 0,
ino: 0,
kind: 'other',
initialRecursive: true,
});
function statKind(stats) {
return stats.isSymbolicLink()
? 'symlink'
: stats.isDirectory()
? 'directory'
: stats.isFile()
? 'file'
: 'other';
}
function sameStatFact(fact, stats) {
const current = statIdentity(stats);
return (fact?.size === current.size &&
fact.mtimeMs === current.mtimeMs &&
fact.ino === current.ino &&
fact.kind === statKind(stats));
}
function withinWindow(fact, trigger, window) {
return (fact?.observedAt !== undefined &&
trigger.observedAt >= fact.observedAt &&
trigger.observedAt - fact.observedAt <= window);
}
/** A directory membership snapshot owned by one public watcher. */
export class DirEntry {
path;
items = new Set();
constructor(dir) {
this.path = dir;
}
add(item) {
// A filesystem root's basename is '' and its dirname is itself; listing ''
// would make the root its own child and unlink everything after a scan.
if (item !== CURRENT && item !== PARENT && item !== '')
this.items.add(item);
}
/** Returns true when removing `item` left the directory empty. */
remove(item) {
return this.items.delete(item) && this.items.size === 0;
}
has(item) {
return this.items.has(item);
}
getChildren() {
return [...this.items];
}
dispose() {
this.items.clear();
this.path = '';
}
}
/**
* Per-watcher filesystem truth. Backends never mutate this object directly;
* scanner and reconciliation stages commit observations through it.
*/
export class TreeState {
watched = new Map();
observed = new Map();
symlinkPaths = new Map();
recursiveRoots = new Set();
observesNative;
constructor(observesNative) {
this.observesNative = observesNative;
}
getDirectory(directory) {
const dir = sp.resolve(directory);
const key = logicalPathKey(dir);
let entry = this.watched.get(key);
if (!entry) {
entry = new DirEntry(dir);
this.watched.set(key, entry);
}
return entry;
}
/** Looks up a directory entry without creating one. */
peekDirectory(directory) {
return this.watched.get(logicalPathKey(sp.resolve(directory)));
}
recordObserved(path, stats, transition, trigger, initialRecursive = false, retainWithoutTrigger = false) {
if (!this.observesNative())
return;
if (!trigger && !initialRecursive && !retainWithoutTrigger)
return;
const { size, mtimeMs, ino } = statIdentity(stats);
this.observed.set(logicalPathKey(path), {
size,
mtimeMs,
ino,
kind: statKind(stats),
transition,
rawEvent: trigger?.rawEvent,
relativePath: trigger?.relativePath,
observedAt: trigger?.observedAt,
initialRecursive,
});
}
consumeInitialCreate(path, stats, trigger, ignoreInitial) {
const previous = this.observed.get(logicalPathKey(path));
if (!previous?.initialRecursive || trigger.rawEvent !== 'rename')
return;
this.recordObserved(path, stats, 'add', trigger);
if (!ignoreInitial)
return false;
return stats.isDirectory() ? EVENTS.ADD_DIR : EVENTS.ADD;
}
markInitialCreate(path) {
const key = logicalPathKey(path);
const fact = this.observed.get(key);
if (fact)
fact.initialRecursive = true;
else
this.observed.set(key, { ...INITIAL_OBSERVATION_FACT });
}
clearInitialCreate(path) {
const fact = this.observed.get(logicalPathKey(path));
if (fact)
fact.initialRecursive = false;
}
clearInitialCreates(root) {
const rootKey = logicalPathKey(root);
for (const [path, fact] of this.observed) {
if (isSameOrInside(rootKey, path) && fact.initialRecursive)
fact.initialRecursive = false;
}
}
/** True when a tracked directory path now names a different directory inode. */
replacedSinceObserved(path, stats) {
const previous = this.observed.get(logicalPathKey(path));
const { ino } = statIdentity(stats);
return (previous?.kind === 'directory' &&
stats.isDirectory() &&
previous.ino !== 0 &&
ino !== 0 &&
previous.ino !== ino);
}
/** True when `stats` no longer matches a recorded fact of the same kind. */
changedSinceObserved(path, stats) {
const previous = this.observed.get(logicalPathKey(path));
return (previous !== undefined && previous.kind === statKind(stats) && !sameStatFact(previous, stats));
}
isDuplicateObservation(path, stats, trigger) {
const previous = this.observed.get(logicalPathKey(path));
const sameFact = sameStatFact(previous, stats);
// Burst windows only collapse callbacks that confirm the fact already
// published. A different fact is a write that landed after that stat.
const duplicateCreate = sameFact &&
previous?.transition === 'add' &&
previous?.relativePath === trigger.relativePath &&
previous.rawEvent === 'rename' &&
trigger.rawEvent === 'change' &&
withinWindow(previous, trigger, RECURSIVE_CREATE_BURST_WINDOW);
const duplicateUnchanged = sameFact &&
stats.isFile() &&
!previous?.initialRecursive &&
(previous?.transition === 'add' || trigger.relativePath === null);
const duplicateWrite = sameFact &&
previous?.transition === 'change' &&
previous.relativePath === trigger.relativePath &&
withinWindow(previous, trigger, RECURSIVE_WRITE_BURST_WINDOW) &&
previous.rawEvent === trigger.rawEvent;
const duplicate = duplicateUnchanged || duplicateCreate || duplicateWrite;
const transition = duplicateWrite
? undefined
: duplicateUnchanged
? previous?.transition
: 'change';
this.recordObserved(path, stats, transition, trigger);
return duplicate;
}
dispose() {
this.watched.forEach((entry) => entry.dispose());
this.watched.clear();
this.observed.clear();
this.symlinkPaths.clear();
this.recursiveRoots.clear();
}
}