428 lines
10 KiB
TypeScript
428 lines
10 KiB
TypeScript
|
|
/* IMPORT */
|
|
|
|
import path from 'node:path';
|
|
import {DEBOUNCE, DEPTH, LIMIT, HAS_NATIVE_RECURSION, IS_WINDOWS} from './constants.js';
|
|
import {FSTargetEvent, FSWatcherEvent, TargetEvent} from './enums.js';
|
|
import Utils from './utils.js';
|
|
import type Watcher from './watcher.js';
|
|
import type {Event, FSWatcher, Handler, HandlerBatched, Path, WatcherOptions, WatcherConfig} from './types.js';
|
|
|
|
/* MAIN */
|
|
|
|
class WatcherHandler {
|
|
|
|
/* VARIABLES */
|
|
|
|
base?: WatcherHandler;
|
|
watcher: Watcher;
|
|
handler: Handler;
|
|
handlerBatched: HandlerBatched;
|
|
fswatcher: FSWatcher;
|
|
options: WatcherOptions;
|
|
folderPath: Path;
|
|
filePath?: Path;
|
|
|
|
/* CONSTRUCTOR */
|
|
|
|
constructor ( watcher: Watcher, config: WatcherConfig, base?: WatcherHandler ) {
|
|
|
|
this.base = base;
|
|
this.watcher = watcher;
|
|
this.handler = config.handler;
|
|
this.fswatcher = config.watcher;
|
|
this.options = config.options;
|
|
this.folderPath = config.folderPath;
|
|
this.filePath = config.filePath;
|
|
|
|
this.handlerBatched = this.base ? this.base.onWatcherEvent.bind ( this.base ) : this._makeHandlerBatched ( this.options.debounce ); //UGLY
|
|
|
|
}
|
|
|
|
/* HELPERS */
|
|
|
|
_isSubRoot ( targetPath: Path ): boolean { // Only events inside the watched root are emitted
|
|
|
|
if ( this.filePath ) {
|
|
|
|
return targetPath === this.filePath;
|
|
|
|
} else {
|
|
|
|
return targetPath === this.folderPath || Utils.fs.isSubPath ( this.folderPath, targetPath );
|
|
|
|
}
|
|
|
|
}
|
|
|
|
_makeHandlerBatched ( delay: number = DEBOUNCE ) {
|
|
|
|
return (() => {
|
|
|
|
let lock = this.watcher._readyWait; // ~Ensuring no two flushes are active in parallel, or before the watcher is ready
|
|
let initials: Event[] = [];
|
|
let regulars: Set<Path> = new Set ();
|
|
|
|
const flush = async ( initials: Event[], regulars: Set<Path> ): Promise<void> => {
|
|
|
|
const initialEvents = this.options.ignoreInitial ? [] : initials;
|
|
const regularEvents = await this.eventsPopulate ([ ...regulars ]);
|
|
const events = this.eventsDeduplicate ([ ...initialEvents, ...regularEvents ]);
|
|
|
|
this.onTargetEvents ( events );
|
|
|
|
};
|
|
|
|
const flushDebounced = Utils.lang.debounce ( () => {
|
|
|
|
if ( this.watcher.isClosed () ) return;
|
|
|
|
lock = flush ( initials, regulars );
|
|
|
|
initials = [];
|
|
regulars = new Set ();
|
|
|
|
}, delay );
|
|
|
|
return async ( event: FSTargetEvent, targetPath: Path = '', isInitial: boolean = false ): Promise<void> => {
|
|
|
|
if ( isInitial ) { // Poll immediately
|
|
|
|
await this.eventsPopulate ( [targetPath], initials, true );
|
|
|
|
} else { // Poll later
|
|
|
|
regulars.add ( targetPath );
|
|
|
|
}
|
|
|
|
lock.then ( flushDebounced );
|
|
|
|
};
|
|
|
|
})();
|
|
|
|
}
|
|
|
|
/* EVENT HELPERS */
|
|
|
|
eventsDeduplicate ( events: Event[] ): Event[] {
|
|
|
|
if ( events.length < 2 ) return events;
|
|
|
|
const targetsEventPrev: Record<Path, TargetEvent> = {};
|
|
|
|
return events.reduce<Event[]> ( ( acc, event ) => {
|
|
|
|
const [targetEvent, targetPath] = event;
|
|
const targetEventPrev = targetsEventPrev[targetPath];
|
|
|
|
if ( targetEvent === targetEventPrev ) return acc; // Same event, ignoring
|
|
|
|
if ( targetEvent === TargetEvent.CHANGE && targetEventPrev === TargetEvent.ADD ) return acc; // "change" after "add", ignoring
|
|
|
|
targetsEventPrev[targetPath] = targetEvent;
|
|
|
|
acc.push ( event );
|
|
|
|
return acc;
|
|
|
|
}, [] );
|
|
|
|
}
|
|
|
|
async eventsPopulate ( targetPaths: Path[], events: Event[] = [], isInitial: boolean = false ): Promise<Event[]> {
|
|
|
|
await Promise.all ( targetPaths.map ( async targetPath => {
|
|
|
|
const targetEvents = await this.watcher._poller.update ( targetPath, this.options.pollingTimeout );
|
|
|
|
await Promise.all ( targetEvents.map ( async event => {
|
|
|
|
events.push ([ event, targetPath ]);
|
|
|
|
if ( event === TargetEvent.ADD_DIR ) {
|
|
|
|
await this.eventsPopulateAddDir ( targetPaths, targetPath, events, isInitial );
|
|
|
|
} else if ( event === TargetEvent.UNLINK_DIR ) {
|
|
|
|
await this.eventsPopulateUnlinkDir ( targetPaths, targetPath, events, isInitial );
|
|
|
|
}
|
|
|
|
}));
|
|
|
|
}));
|
|
|
|
return events;
|
|
|
|
};
|
|
|
|
async eventsPopulateAddDir ( targetPaths: Path[], targetPath: Path, events: Event[] = [], isInitial: boolean = false ): Promise<Event[]> {
|
|
|
|
if ( isInitial ) return events;
|
|
|
|
const depth = this.options.recursive ? this.options.depth ?? DEPTH : Math.min ( 1, this.options.depth ?? DEPTH );
|
|
const limit = this.options.limit ?? LIMIT;
|
|
const [directories, files] = await Utils.fs.readdir ( targetPath, this.options.ignore, depth, limit, this.watcher._closeSignal );
|
|
const targetSubPaths = [...directories, ...files];
|
|
|
|
await Promise.all ( targetSubPaths.map ( targetSubPath => {
|
|
|
|
if ( this.watcher.isIgnored ( targetSubPath, this.options.ignore ) ) return;
|
|
|
|
if ( targetPaths.includes ( targetSubPath ) ) return;
|
|
|
|
return this.eventsPopulate ( [targetSubPath], events, true );
|
|
|
|
}));
|
|
|
|
return events;
|
|
|
|
}
|
|
|
|
async eventsPopulateUnlinkDir ( targetPaths: Path[], targetPath: Path, events: Event[] = [], isInitial: boolean = false ): Promise<Event[]> {
|
|
|
|
if ( isInitial ) return events;
|
|
|
|
for ( const folderPathOther of this.watcher._poller.stats.keys () ) {
|
|
|
|
if ( !Utils.fs.isSubPath ( targetPath, folderPathOther ) ) continue;
|
|
|
|
if ( targetPaths.includes ( folderPathOther ) ) continue;
|
|
|
|
await this.eventsPopulate ( [folderPathOther], events, true );
|
|
|
|
}
|
|
|
|
return events;
|
|
|
|
}
|
|
|
|
/* EVENT HANDLERS */
|
|
|
|
onTargetAdd ( targetPath: Path ): void {
|
|
|
|
if ( this._isSubRoot ( targetPath ) ) {
|
|
|
|
if ( this.options.renameDetection ) {
|
|
|
|
this.watcher._locker.getLockTargetAdd ( targetPath, this.options.renameTimeout );
|
|
|
|
} else {
|
|
|
|
this.watcher.event ( TargetEvent.ADD, targetPath );
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
onTargetAddDir ( targetPath: Path ): void {
|
|
|
|
if ( targetPath !== this.folderPath && this.options.recursive && ( !HAS_NATIVE_RECURSION && this.options.native !== false ) ) {
|
|
|
|
this.watcher.watchDirectory ( targetPath, this.options, this.handler, undefined, this.base || this );
|
|
|
|
}
|
|
|
|
if ( this._isSubRoot ( targetPath ) ) {
|
|
|
|
if ( this.options.renameDetection ) {
|
|
|
|
this.watcher._locker.getLockTargetAddDir ( targetPath, this.options.renameTimeout );
|
|
|
|
} else {
|
|
|
|
this.watcher.event ( TargetEvent.ADD_DIR, targetPath );
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
onTargetChange ( targetPath: Path ): void {
|
|
|
|
if ( this._isSubRoot ( targetPath ) ) {
|
|
|
|
this.watcher.event ( TargetEvent.CHANGE, targetPath );
|
|
|
|
}
|
|
|
|
}
|
|
|
|
onTargetUnlink ( targetPath: Path ): void {
|
|
|
|
this.watcher.watchersClose ( path.dirname ( targetPath ), targetPath, false );
|
|
|
|
if ( this._isSubRoot ( targetPath ) ) {
|
|
|
|
if ( this.options.renameDetection ) {
|
|
|
|
this.watcher._locker.getLockTargetUnlink ( targetPath, this.options.renameTimeout );
|
|
|
|
} else {
|
|
|
|
this.watcher.event ( TargetEvent.UNLINK, targetPath );
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
onTargetUnlinkDir ( targetPath: Path ): void {
|
|
|
|
this.watcher.watchersClose ( path.dirname ( targetPath ), targetPath, false );
|
|
|
|
this.watcher.watchersClose ( targetPath );
|
|
|
|
if ( this._isSubRoot ( targetPath ) ) {
|
|
|
|
if ( this.options.renameDetection ) {
|
|
|
|
this.watcher._locker.getLockTargetUnlinkDir ( targetPath, this.options.renameTimeout );
|
|
|
|
} else {
|
|
|
|
this.watcher.event ( TargetEvent.UNLINK_DIR, targetPath );
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
onTargetEvent ( event: Event ): void {
|
|
|
|
const [targetEvent, targetPath] = event;
|
|
|
|
if ( targetEvent === TargetEvent.ADD ) {
|
|
|
|
this.onTargetAdd ( targetPath );
|
|
|
|
} else if ( targetEvent === TargetEvent.ADD_DIR ) {
|
|
|
|
this.onTargetAddDir ( targetPath );
|
|
|
|
} else if ( targetEvent === TargetEvent.CHANGE ) {
|
|
|
|
this.onTargetChange ( targetPath );
|
|
|
|
} else if ( targetEvent === TargetEvent.UNLINK ) {
|
|
|
|
this.onTargetUnlink ( targetPath );
|
|
|
|
} else if ( targetEvent === TargetEvent.UNLINK_DIR ) {
|
|
|
|
this.onTargetUnlinkDir ( targetPath );
|
|
|
|
}
|
|
|
|
}
|
|
|
|
onTargetEvents ( events: Event[] ): void {
|
|
|
|
for ( const event of events ) {
|
|
|
|
this.onTargetEvent ( event );
|
|
|
|
}
|
|
|
|
}
|
|
|
|
onWatcherEvent ( event?: FSTargetEvent, targetPath?: Path, isInitial: boolean = false ): Promise<void> {
|
|
|
|
return this.handlerBatched ( event, targetPath, isInitial );
|
|
|
|
}
|
|
|
|
onWatcherChange ( event: FSTargetEvent = FSTargetEvent.CHANGE, targetName?: string | null ): void {
|
|
|
|
if ( this.watcher.isClosed () ) return;
|
|
|
|
const targetPath = path.resolve ( this.folderPath, targetName || '' );
|
|
|
|
if ( this.filePath && targetPath !== this.folderPath && targetPath !== this.filePath ) return;
|
|
|
|
if ( this.watcher.isIgnored ( targetPath, this.options.ignore ) ) return;
|
|
|
|
this.onWatcherEvent ( event, targetPath );
|
|
|
|
}
|
|
|
|
onWatcherError ( error: NodeJS.ErrnoException ): void {
|
|
|
|
if ( IS_WINDOWS && error.code === 'EPERM' ) { // This may happen when a folder is deleted
|
|
|
|
this.onWatcherChange ( FSTargetEvent.CHANGE, '' );
|
|
|
|
} else {
|
|
|
|
this.watcher.error ( error );
|
|
|
|
}
|
|
|
|
}
|
|
|
|
/* API */
|
|
|
|
async init (): Promise<void> {
|
|
|
|
await this.initWatcherEvents ();
|
|
await this.initInitialEvents ();
|
|
|
|
}
|
|
|
|
async initWatcherEvents (): Promise<void> {
|
|
|
|
const onChange = this.onWatcherChange.bind ( this );
|
|
|
|
this.fswatcher.on ( FSWatcherEvent.CHANGE, onChange );
|
|
|
|
const onError = this.onWatcherError.bind ( this );
|
|
|
|
this.fswatcher.on ( FSWatcherEvent.ERROR, onError );
|
|
|
|
}
|
|
|
|
async initInitialEvents (): Promise<void> {
|
|
|
|
const isInitial = !this.watcher.isReady (); // "isInitial" => is ignorable via the "ignoreInitial" option
|
|
|
|
if ( this.filePath ) { // Single initial path
|
|
|
|
if ( this.watcher._poller.stats.has ( this.filePath ) ) return; // Already polled
|
|
|
|
await this.onWatcherEvent ( FSTargetEvent.CHANGE, this.filePath, isInitial );
|
|
|
|
} else { // Multiple initial paths
|
|
|
|
const depth = this.options.recursive && ( HAS_NATIVE_RECURSION && this.options.native !== false ) ? this.options.depth ?? DEPTH : Math.min ( 1, this.options.depth ?? DEPTH );
|
|
const limit = this.options.limit ?? LIMIT;
|
|
const [directories, files] = await Utils.fs.readdir ( this.folderPath, this.options.ignore, depth, limit, this.watcher._closeSignal, this.options.readdirMap );
|
|
const targetPaths = [this.folderPath, ...directories, ...files];
|
|
|
|
await Promise.all ( targetPaths.map ( targetPath => {
|
|
|
|
if ( this.watcher._poller.stats.has ( targetPath ) ) return; // Already polled
|
|
|
|
if ( this.watcher.isIgnored ( targetPath, this.options.ignore ) ) return;
|
|
|
|
return this.onWatcherEvent ( FSTargetEvent.CHANGE, targetPath, isInitial );
|
|
|
|
}));
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
/* EXPORT */
|
|
|
|
export default WatcherHandler;
|