update
This commit is contained in:
32
ts/constants.ts
Normal file
32
ts/constants.ts
Normal file
@ -0,0 +1,32 @@
|
||||
|
||||
/* IMPORT */
|
||||
|
||||
import os from 'node:os';
|
||||
|
||||
/* MAIN */
|
||||
|
||||
const DEBOUNCE = 300;
|
||||
|
||||
const DEPTH = 20;
|
||||
|
||||
const LIMIT = 10_000_000;
|
||||
|
||||
const PLATFORM = os.platform ();
|
||||
|
||||
const IS_LINUX = ( PLATFORM === 'linux' );
|
||||
|
||||
const IS_MAC = ( PLATFORM === 'darwin' );
|
||||
|
||||
const IS_WINDOWS = ( PLATFORM === 'win32' );
|
||||
|
||||
const HAS_NATIVE_RECURSION = IS_MAC || IS_WINDOWS;
|
||||
|
||||
const POLLING_INTERVAL = 3000;
|
||||
|
||||
const POLLING_TIMEOUT = 20000;
|
||||
|
||||
const RENAME_TIMEOUT = 1250;
|
||||
|
||||
/* EXPORT */
|
||||
|
||||
export {DEBOUNCE, DEPTH, LIMIT, HAS_NATIVE_RECURSION, IS_LINUX, IS_MAC, IS_WINDOWS, PLATFORM, POLLING_INTERVAL, POLLING_TIMEOUT, RENAME_TIMEOUT};
|
152
ts/dettle/debounce.ts
Normal file
152
ts/dettle/debounce.ts
Normal file
@ -0,0 +1,152 @@
|
||||
|
||||
/* IMPORT */
|
||||
|
||||
import type {FN, Debounced} from './types.js';
|
||||
|
||||
/* MAIN */
|
||||
|
||||
const debounce = <Args extends unknown[]> ( fn: FN<Args, unknown>, wait: number = 1, options?: { leading?: boolean, trailing?: boolean, maxWait?: number } ): Debounced<Args> => {
|
||||
|
||||
/* VARIABLES */
|
||||
|
||||
wait = Math.max ( 1, wait );
|
||||
|
||||
const leading = options?.leading ?? false;
|
||||
const trailing = options?.trailing ?? true;
|
||||
const maxWait = Math.max ( options?.maxWait ?? Infinity, wait );
|
||||
|
||||
let args: Args | undefined;
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
let timestampCall = 0;
|
||||
let timestampInvoke = 0;
|
||||
|
||||
/* HELPERS */
|
||||
|
||||
const getInstantData = (): [number, boolean] => {
|
||||
|
||||
const timestamp = Date.now ();
|
||||
const elapsedCall = timestamp - timestampCall;
|
||||
const elapsedInvoke = timestamp - timestampInvoke;
|
||||
const isInvoke = ( elapsedCall >= wait || elapsedInvoke >= maxWait );
|
||||
|
||||
return [timestamp, isInvoke];
|
||||
|
||||
};
|
||||
|
||||
const invoke = ( timestamp: number ): void => {
|
||||
|
||||
timestampInvoke = timestamp;
|
||||
|
||||
if ( !args ) return; // This should never happen
|
||||
|
||||
const _args = args;
|
||||
|
||||
args = undefined;
|
||||
|
||||
fn.apply ( undefined, _args );
|
||||
|
||||
};
|
||||
|
||||
const onCancel = (): void => {
|
||||
|
||||
resetTimeout ( 0 );
|
||||
|
||||
};
|
||||
|
||||
const onFlush = (): void => {
|
||||
|
||||
if ( !timeout ) return;
|
||||
|
||||
onCancel ();
|
||||
|
||||
invoke ( Date.now () );
|
||||
|
||||
};
|
||||
|
||||
const onLeading = ( timestamp: number ): void => {
|
||||
|
||||
timestampInvoke = timestamp;
|
||||
|
||||
if ( leading ) return invoke ( timestamp );
|
||||
|
||||
};
|
||||
|
||||
const onTrailing = ( timestamp: number ): void => {
|
||||
|
||||
if ( trailing && args ) return invoke ( timestamp );
|
||||
|
||||
args = undefined;
|
||||
|
||||
};
|
||||
|
||||
const onTimeout = (): void => {
|
||||
|
||||
timeout = undefined;
|
||||
|
||||
const [timestamp, isInvoking] = getInstantData ();
|
||||
|
||||
if ( isInvoking ) return onTrailing ( timestamp );
|
||||
|
||||
return updateTimeout ( timestamp );
|
||||
|
||||
};
|
||||
|
||||
const updateTimeout = ( timestamp: number ): void => {
|
||||
|
||||
const elapsedCall = timestamp - timestampCall;
|
||||
const elapsedInvoke = timestamp - timestampInvoke;
|
||||
const remainingCall = wait - elapsedCall;
|
||||
const remainingInvoke = maxWait - elapsedInvoke;
|
||||
const ms = Math.min ( remainingCall, remainingInvoke );
|
||||
|
||||
return resetTimeout ( ms );
|
||||
|
||||
};
|
||||
|
||||
const resetTimeout = ( ms: number ): void => {
|
||||
|
||||
if ( timeout ) clearTimeout ( timeout );
|
||||
|
||||
if ( ms <= 0 ) return;
|
||||
|
||||
timeout = setTimeout ( onTimeout, ms );
|
||||
|
||||
};
|
||||
|
||||
/* DEBOUNCED */
|
||||
|
||||
const debounced = ( ...argsLatest: Args ): void => {
|
||||
|
||||
const [timestamp, isInvoking] = getInstantData ();
|
||||
const hadTimeout = !!timeout;
|
||||
|
||||
args = argsLatest;
|
||||
timestampCall = timestamp;
|
||||
|
||||
if ( isInvoking || !timeout ) resetTimeout ( wait );
|
||||
|
||||
if ( isInvoking ) {
|
||||
|
||||
if ( !hadTimeout ) return onLeading ( timestamp );
|
||||
|
||||
return invoke ( timestamp );
|
||||
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
/* DEBOUNCED UTILITIES */
|
||||
|
||||
debounced.cancel = onCancel;
|
||||
|
||||
debounced.flush = onFlush;
|
||||
|
||||
/* RETURN */
|
||||
|
||||
return debounced;
|
||||
|
||||
};
|
||||
|
||||
/* EXPORT */
|
||||
|
||||
export default debounce;
|
9
ts/dettle/index.ts
Executable file
9
ts/dettle/index.ts
Executable file
@ -0,0 +1,9 @@
|
||||
|
||||
/* IMPORT */
|
||||
|
||||
import debounce from './debounce.js';
|
||||
import throttle from './throttle.js';
|
||||
|
||||
/* EXPORT */
|
||||
|
||||
export {debounce, throttle};
|
21
ts/dettle/license
Normal file
21
ts/dettle/license
Normal file
@ -0,0 +1,21 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2023-present Fabio Spampinato
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a
|
||||
copy of this software and associated documentation files (the "Software"),
|
||||
to deal in the Software without restriction, including without limitation
|
||||
the rights to use, copy, modify, merge, publish, distribute, sublicense,
|
||||
and/or sell copies of the Software, and to permit persons to whom the
|
||||
Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
DEALINGS IN THE SOFTWARE.
|
21
ts/dettle/throttle.ts
Normal file
21
ts/dettle/throttle.ts
Normal file
@ -0,0 +1,21 @@
|
||||
|
||||
/* IMPORT */
|
||||
|
||||
import debounce from './debounce.js';
|
||||
import type {FN, Throttled} from './types.js';
|
||||
|
||||
/* MAIN */
|
||||
|
||||
const throttle = <Args extends unknown[]> ( fn: FN<Args, unknown>, wait: number = 1, options?: { leading?: boolean, trailing?: boolean } ): Throttled<Args> => {
|
||||
|
||||
return debounce ( fn, wait, {
|
||||
maxWait: wait,
|
||||
leading: options?.leading ?? true,
|
||||
trailing: options?.trailing ?? true
|
||||
});
|
||||
|
||||
};
|
||||
|
||||
/* EXPORT */
|
||||
|
||||
export default throttle;
|
14
ts/dettle/types.ts
Normal file
14
ts/dettle/types.ts
Normal file
@ -0,0 +1,14 @@
|
||||
|
||||
/* MAIN */
|
||||
|
||||
type Callback = () => void;
|
||||
|
||||
type FN<Args extends unknown[], Return> = ( ...args: Args ) => Return;
|
||||
|
||||
type Debounced<Args extends unknown[]> = FN<Args, void> & { cancel: Callback, flush: Callback };
|
||||
|
||||
type Throttled<Args extends unknown[]> = FN<Args, void> & { cancel: Callback, flush: Callback };
|
||||
|
||||
/* EXPORT */
|
||||
|
||||
export type {Callback, FN, Debounced, Throttled};
|
38
ts/enums.ts
Normal file
38
ts/enums.ts
Normal file
@ -0,0 +1,38 @@
|
||||
|
||||
/* MAIN */
|
||||
|
||||
const enum FileType {
|
||||
DIR = 1,
|
||||
FILE = 2
|
||||
}
|
||||
|
||||
const enum FSTargetEvent {
|
||||
CHANGE = 'change',
|
||||
RENAME = 'rename'
|
||||
}
|
||||
|
||||
const enum FSWatcherEvent {
|
||||
CHANGE = 'change',
|
||||
ERROR = 'error'
|
||||
}
|
||||
|
||||
const enum TargetEvent {
|
||||
ADD = 'add',
|
||||
ADD_DIR = 'addDir',
|
||||
CHANGE = 'change',
|
||||
RENAME = 'rename',
|
||||
RENAME_DIR = 'renameDir',
|
||||
UNLINK = 'unlink',
|
||||
UNLINK_DIR = 'unlinkDir'
|
||||
}
|
||||
|
||||
const enum WatcherEvent {
|
||||
ALL = 'all',
|
||||
CLOSE = 'close',
|
||||
ERROR = 'error',
|
||||
READY = 'ready'
|
||||
}
|
||||
|
||||
/* EXPORT */
|
||||
|
||||
export {FileType, FSTargetEvent, FSWatcherEvent, TargetEvent, WatcherEvent};
|
144
ts/lazy_map_set.ts
Normal file
144
ts/lazy_map_set.ts
Normal file
@ -0,0 +1,144 @@
|
||||
|
||||
/* IMPORT */
|
||||
|
||||
import Utils from './utils.js';
|
||||
|
||||
/* MAIN */
|
||||
|
||||
//TODO: Maybe publish this as a standalone module
|
||||
|
||||
class LazyMapSet<K, V> {
|
||||
|
||||
/* VARIABLES */
|
||||
|
||||
private map: Map<K, Set<V> | V> = new Map ();
|
||||
|
||||
/* API */
|
||||
|
||||
clear (): void {
|
||||
|
||||
this.map.clear ();
|
||||
|
||||
}
|
||||
|
||||
delete ( key: K, value?: V ): boolean {
|
||||
|
||||
if ( Utils.lang.isUndefined ( value ) ) {
|
||||
|
||||
return this.map.delete ( key );
|
||||
|
||||
} else if ( this.map.has ( key ) ) {
|
||||
|
||||
const values = this.map.get ( key );
|
||||
|
||||
if ( Utils.lang.isSet ( values ) ) {
|
||||
|
||||
const deleted = values.delete ( value );
|
||||
|
||||
if ( !values.size ) {
|
||||
|
||||
this.map.delete ( key );
|
||||
|
||||
}
|
||||
|
||||
return deleted;
|
||||
|
||||
} else if ( values === value ) {
|
||||
|
||||
this.map.delete ( key );
|
||||
|
||||
return true;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return false;
|
||||
|
||||
}
|
||||
|
||||
find ( key: K, iterator: ( value: V ) => boolean ): V | undefined {
|
||||
|
||||
if ( this.map.has ( key ) ) {
|
||||
|
||||
const values = this.map.get ( key );
|
||||
|
||||
if ( Utils.lang.isSet ( values ) ) {
|
||||
|
||||
return Array.from ( values ).find ( iterator );
|
||||
|
||||
} else if ( iterator ( values! ) ) { //TSC
|
||||
|
||||
return values;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return undefined;
|
||||
|
||||
}
|
||||
|
||||
get ( key: K ): Set<V> | V | undefined {
|
||||
|
||||
return this.map.get ( key );
|
||||
|
||||
}
|
||||
|
||||
has ( key: K, value?: V ): boolean {
|
||||
|
||||
if ( Utils.lang.isUndefined ( value ) ) {
|
||||
|
||||
return this.map.has ( key );
|
||||
|
||||
} else if ( this.map.has ( key ) ) {
|
||||
|
||||
const values = this.map.get ( key );
|
||||
|
||||
if ( Utils.lang.isSet ( values ) ) {
|
||||
|
||||
return values.has ( value );
|
||||
|
||||
} else {
|
||||
|
||||
return ( values === value );
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return false;
|
||||
|
||||
}
|
||||
|
||||
set ( key: K, value: V ): this {
|
||||
|
||||
if ( this.map.has ( key ) ) {
|
||||
|
||||
const values = this.map.get ( key );
|
||||
|
||||
if ( Utils.lang.isSet ( values ) ) {
|
||||
|
||||
values.add ( value );
|
||||
|
||||
} else if ( values !== value ) {
|
||||
|
||||
this.map.set ( key, new Set ([ values!, value ]) ); //TSC
|
||||
|
||||
}
|
||||
|
||||
} else {
|
||||
|
||||
this.map.set ( key, value );
|
||||
|
||||
}
|
||||
|
||||
return this;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/* EXPORT */
|
||||
|
||||
export default LazyMapSet;
|
8
ts/promise-make-naked/constants.ts
Normal file
8
ts/promise-make-naked/constants.ts
Normal file
@ -0,0 +1,8 @@
|
||||
|
||||
/* MAIN */
|
||||
|
||||
const NOOP = (): void => {};
|
||||
|
||||
/* EXPORT */
|
||||
|
||||
export {NOOP};
|
53
ts/promise-make-naked/index.ts
Executable file
53
ts/promise-make-naked/index.ts
Executable file
@ -0,0 +1,53 @@
|
||||
|
||||
/* IMPORT */
|
||||
|
||||
import {NOOP} from './constants.js';
|
||||
import type {PromiseResolve, PromiseReject, Result} from './types.js';
|
||||
|
||||
/* MAIN */
|
||||
|
||||
const makeNakedPromise = <T> (): Result<T> => {
|
||||
|
||||
let resolve: PromiseResolve<T> = NOOP;
|
||||
let reject: PromiseReject = NOOP;
|
||||
|
||||
let resolved = false;
|
||||
let rejected = false;
|
||||
|
||||
const promise = new Promise<T> ( ( res, rej ): void => {
|
||||
|
||||
resolve = value => {
|
||||
resolved = true;
|
||||
return res ( value );
|
||||
};
|
||||
|
||||
reject = value => {
|
||||
rejected = true;
|
||||
return rej ( value );
|
||||
};
|
||||
|
||||
});
|
||||
|
||||
const isPending = (): boolean => !resolved && !rejected;
|
||||
const isResolved = (): boolean => resolved;
|
||||
const isRejected = (): boolean => rejected;
|
||||
|
||||
return {promise, resolve, reject, isPending, isResolved, isRejected};
|
||||
|
||||
};
|
||||
|
||||
/* UTILITIES */
|
||||
|
||||
makeNakedPromise.wrap = async <T> ( fn: ( result: Result<T> ) => void ): Promise<T> => {
|
||||
|
||||
const result = makeNakedPromise<T> ();
|
||||
|
||||
await fn ( result );
|
||||
|
||||
return result.promise;
|
||||
|
||||
};
|
||||
|
||||
/* EXPORT */
|
||||
|
||||
export default makeNakedPromise;
|
21
ts/promise-make-naked/license
Normal file
21
ts/promise-make-naked/license
Normal file
@ -0,0 +1,21 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2021-present Fabio Spampinato
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a
|
||||
copy of this software and associated documentation files (the "Software"),
|
||||
to deal in the Software without restriction, including without limitation
|
||||
the rights to use, copy, modify, merge, publish, distribute, sublicense,
|
||||
and/or sell copies of the Software, and to permit persons to whom the
|
||||
Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
DEALINGS IN THE SOFTWARE.
|
19
ts/promise-make-naked/types.ts
Normal file
19
ts/promise-make-naked/types.ts
Normal file
@ -0,0 +1,19 @@
|
||||
|
||||
/* MAIN */
|
||||
|
||||
type PromiseResolve <T> = ( value: T | PromiseLike<T> ) => void;
|
||||
|
||||
type PromiseReject = ( reason?: unknown ) => void;
|
||||
|
||||
type Result <T> = {
|
||||
promise: Promise<T>,
|
||||
resolve: PromiseResolve<T>,
|
||||
reject: PromiseReject,
|
||||
isPending: () => boolean,
|
||||
isResolved: () => boolean,
|
||||
isRejected: () => boolean
|
||||
};
|
||||
|
||||
/* EXPORT */
|
||||
|
||||
export type {PromiseResolve, PromiseReject, Result};
|
246
ts/tiny-readdir/index.ts
Executable file
246
ts/tiny-readdir/index.ts
Executable file
@ -0,0 +1,246 @@
|
||||
|
||||
/* IMPORT */
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import {isFunction, makeCounterPromise} from './utils.js';
|
||||
import type {Options, ResultDirectory, ResultDirectories, Result} from './types.js';
|
||||
|
||||
/* MAIN */
|
||||
|
||||
//TODO: Streamline the type of dirnmaps
|
||||
|
||||
const readdir = ( rootPath: string, options?: Options ): Promise<Result> => {
|
||||
|
||||
const followSymlinks = options?.followSymlinks ?? false;
|
||||
const maxDepth = options?.depth ?? Infinity;
|
||||
const maxPaths = options?.limit ?? Infinity;
|
||||
const ignore = options?.ignore ?? (() => false);
|
||||
const isIgnored = isFunction ( ignore ) ? ignore : ( targetPath: string ) => ignore.test ( targetPath );
|
||||
const signal = options?.signal ?? { aborted: false };
|
||||
const directories: string[] = [];
|
||||
const directoriesNames: Set<string> = new Set ();
|
||||
const directoriesNamesToPaths: Record<string, string[]> = {};
|
||||
const files: string[] = [];
|
||||
const filesNames: Set<string> = new Set ();
|
||||
const filesNamesToPaths: Record<string, string[]> = {};
|
||||
const symlinks: string[] = [];
|
||||
const symlinksNames: Set<string> = new Set ();
|
||||
const symlinksNamesToPaths: Record<string, string[]> = {};
|
||||
const map: ResultDirectories = {};
|
||||
const visited = new Set<string> ();
|
||||
const resultEmpty: Result = { directories: [], directoriesNames: new Set (), directoriesNamesToPaths: {}, files: [], filesNames: new Set (), filesNamesToPaths: {}, symlinks: [], symlinksNames: new Set (), symlinksNamesToPaths: {}, map: {} };
|
||||
const result: Result = { directories, directoriesNames, directoriesNamesToPaths, files, filesNames, filesNamesToPaths, symlinks, symlinksNames, symlinksNamesToPaths, map };
|
||||
const {promise, increment, decrement} = makeCounterPromise ();
|
||||
|
||||
let foundPaths = 0;
|
||||
|
||||
const handleDirectory = ( dirmap: ResultDirectory, subPath: string, name: string, depth: number ): void => {
|
||||
|
||||
if ( visited.has ( subPath ) ) return;
|
||||
|
||||
if ( foundPaths >= maxPaths ) return;
|
||||
|
||||
foundPaths += 1;
|
||||
dirmap.directories.push ( subPath );
|
||||
dirmap.directoriesNames.add ( name );
|
||||
// dirmap.directoriesNamesToPaths.propertyIsEnumerable(name) || ( dirmap.directoriesNamesToPaths[name] = [] );
|
||||
// dirmap.directoriesNamesToPaths[name].push ( subPath );
|
||||
directories.push ( subPath );
|
||||
directoriesNames.add ( name );
|
||||
directoriesNamesToPaths.propertyIsEnumerable(name) || ( directoriesNamesToPaths[name] = [] );
|
||||
directoriesNamesToPaths[name].push ( subPath );
|
||||
visited.add ( subPath );
|
||||
|
||||
if ( depth >= maxDepth ) return;
|
||||
|
||||
if ( foundPaths >= maxPaths ) return;
|
||||
|
||||
populateResultFromPath ( subPath, depth + 1 );
|
||||
|
||||
};
|
||||
|
||||
const handleFile = ( dirmap: ResultDirectory, subPath: string, name: string ): void => {
|
||||
|
||||
if ( visited.has ( subPath ) ) return;
|
||||
|
||||
if ( foundPaths >= maxPaths ) return;
|
||||
|
||||
foundPaths += 1;
|
||||
dirmap.files.push ( subPath );
|
||||
dirmap.filesNames.add ( name );
|
||||
// dirmap.filesNamesToPaths.propertyIsEnumerable(name) || ( dirmap.filesNamesToPaths[name] = [] );
|
||||
// dirmap.filesNamesToPaths[name].push ( subPath );
|
||||
files.push ( subPath );
|
||||
filesNames.add ( name );
|
||||
filesNamesToPaths.propertyIsEnumerable(name) || ( filesNamesToPaths[name] = [] );
|
||||
filesNamesToPaths[name].push ( subPath );
|
||||
visited.add ( subPath );
|
||||
|
||||
};
|
||||
|
||||
const handleSymlink = ( dirmap: ResultDirectory, subPath: string, name: string, depth: number ): void => {
|
||||
|
||||
if ( visited.has ( subPath ) ) return;
|
||||
|
||||
if ( foundPaths >= maxPaths ) return;
|
||||
|
||||
foundPaths += 1;
|
||||
dirmap.symlinks.push ( subPath );
|
||||
dirmap.symlinksNames.add ( name );
|
||||
// dirmap.symlinksNamesToPaths.propertyIsEnumerable(name) || ( dirmap.symlinksNamesToPaths[name] = [] );
|
||||
// dirmap.symlinksNamesToPaths[name].push ( subPath );
|
||||
symlinks.push ( subPath );
|
||||
symlinksNames.add ( name );
|
||||
symlinksNamesToPaths.propertyIsEnumerable(name) || ( symlinksNamesToPaths[name] = [] );
|
||||
symlinksNamesToPaths[name].push ( subPath );
|
||||
visited.add ( subPath );
|
||||
|
||||
if ( !followSymlinks ) return;
|
||||
|
||||
if ( depth >= maxDepth ) return;
|
||||
|
||||
if ( foundPaths >= maxPaths ) return;
|
||||
|
||||
populateResultFromSymlink ( subPath, depth + 1 );
|
||||
|
||||
};
|
||||
|
||||
const handleStat = ( dirmap: ResultDirectory, rootPath: string, name: string, stat: fs.Stats, depth: number ): void => {
|
||||
|
||||
if ( signal.aborted ) return;
|
||||
|
||||
if ( isIgnored ( rootPath ) ) return;
|
||||
|
||||
if ( stat.isDirectory () ) {
|
||||
|
||||
handleDirectory ( dirmap, rootPath, name, depth );
|
||||
|
||||
} else if ( stat.isFile () ) {
|
||||
|
||||
handleFile ( dirmap, rootPath, name );
|
||||
|
||||
} else if ( stat.isSymbolicLink () ) {
|
||||
|
||||
handleSymlink ( dirmap, rootPath, name, depth );
|
||||
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
const handleDirent = ( dirmap: ResultDirectory, rootPath: string, dirent: fs.Dirent, depth: number ): void => {
|
||||
|
||||
if ( signal.aborted ) return;
|
||||
|
||||
const separator = ( rootPath === path.sep ) ? '' : path.sep;
|
||||
const name = dirent.name;
|
||||
const subPath = `${rootPath}${separator}${name}`;
|
||||
|
||||
if ( isIgnored ( subPath ) ) return;
|
||||
|
||||
if ( dirent.isDirectory () ) {
|
||||
|
||||
handleDirectory ( dirmap, subPath, name, depth );
|
||||
|
||||
} else if ( dirent.isFile () ) {
|
||||
|
||||
handleFile ( dirmap, subPath, name );
|
||||
|
||||
} else if ( dirent.isSymbolicLink () ) {
|
||||
|
||||
handleSymlink ( dirmap, subPath, name, depth );
|
||||
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
const handleDirents = ( dirmap: ResultDirectory, rootPath: string, dirents: fs.Dirent[], depth: number ): void => {
|
||||
|
||||
for ( let i = 0, l = dirents.length; i < l; i++ ) {
|
||||
|
||||
handleDirent ( dirmap, rootPath, dirents[i], depth );
|
||||
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
const populateResultFromPath = ( rootPath: string, depth: number ): void => {
|
||||
|
||||
if ( signal.aborted ) return;
|
||||
|
||||
if ( depth > maxDepth ) return;
|
||||
|
||||
if ( foundPaths >= maxPaths ) return;
|
||||
|
||||
increment ();
|
||||
|
||||
fs.readdir ( rootPath, { withFileTypes: true }, ( error, dirents ) => {
|
||||
|
||||
if ( error ) return decrement ();
|
||||
|
||||
if ( signal.aborted ) return decrement ();
|
||||
|
||||
if ( !dirents.length ) return decrement ();
|
||||
|
||||
const dirmap = map[rootPath] = { directories: [], directoriesNames: new Set (), directoriesNamesToPaths: {}, files: [], filesNames: new Set (), filesNamesToPaths: {}, symlinks: [], symlinksNames: new Set (), symlinksNamesToPaths: {} };
|
||||
|
||||
handleDirents ( dirmap, rootPath, dirents, depth );
|
||||
|
||||
decrement ();
|
||||
|
||||
});
|
||||
|
||||
};
|
||||
|
||||
const populateResultFromSymlink = async ( rootPath: string, depth: number ): Promise<void> => {
|
||||
|
||||
increment ();
|
||||
|
||||
fs.realpath ( rootPath, ( error, realPath ) => {
|
||||
|
||||
if ( error ) return decrement ();
|
||||
|
||||
if ( signal.aborted ) return decrement ();
|
||||
|
||||
fs.stat ( realPath, async ( error, stat ) => {
|
||||
|
||||
if ( error ) return decrement ();
|
||||
|
||||
if ( signal.aborted ) return decrement ();
|
||||
|
||||
const name = path.basename ( realPath );
|
||||
const dirmap = map[rootPath] = { directories: [], directoriesNames: new Set (), directoriesNamesToPaths: {}, files: [], filesNames: new Set (), filesNamesToPaths: {}, symlinks: [], symlinksNames: new Set (), symlinksNamesToPaths: {} };
|
||||
|
||||
handleStat ( dirmap, realPath, name, stat, depth );
|
||||
|
||||
decrement ();
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
};
|
||||
|
||||
const getResult = async ( rootPath: string, depth: number = 1 ): Promise<Result> => {
|
||||
|
||||
rootPath = path.normalize ( rootPath );
|
||||
|
||||
visited.add ( rootPath );
|
||||
|
||||
populateResultFromPath ( rootPath, depth );
|
||||
|
||||
await promise;
|
||||
|
||||
if ( signal.aborted ) return resultEmpty;
|
||||
|
||||
return result;
|
||||
|
||||
};
|
||||
|
||||
return getResult ( rootPath );
|
||||
|
||||
};
|
||||
|
||||
/* EXPORT */
|
||||
|
||||
export default readdir;
|
21
ts/tiny-readdir/license
Normal file
21
ts/tiny-readdir/license
Normal file
@ -0,0 +1,21 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2020-present Fabio Spampinato
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a
|
||||
copy of this software and associated documentation files (the "Software"),
|
||||
to deal in the Software without restriction, including without limitation
|
||||
the rights to use, copy, modify, merge, publish, distribute, sublicense,
|
||||
and/or sell copies of the Software, and to permit persons to whom the
|
||||
Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
DEALINGS IN THE SOFTWARE.
|
38
ts/tiny-readdir/types.ts
Normal file
38
ts/tiny-readdir/types.ts
Normal file
@ -0,0 +1,38 @@
|
||||
|
||||
/* HELPERS */
|
||||
|
||||
type Callback = () => void;
|
||||
|
||||
/* MAIN */
|
||||
|
||||
type Options = {
|
||||
depth?: number,
|
||||
limit?: number,
|
||||
followSymlinks?: boolean,
|
||||
ignore?: (( targetPath: string ) => boolean) | RegExp,
|
||||
signal?: { aborted: boolean }
|
||||
};
|
||||
|
||||
type ResultDirectory = {
|
||||
directories: string[],
|
||||
directoriesNames: Set<string>,
|
||||
directoriesNamesToPaths: Record<string, string[]>,
|
||||
files: string[],
|
||||
filesNames: Set<string>,
|
||||
filesNamesToPaths: Record<string, string[]>,
|
||||
symlinks: string[],
|
||||
symlinksNames: Set<string>,
|
||||
symlinksNamesToPaths: Record<string, string[]>
|
||||
};
|
||||
|
||||
type ResultDirectories = {
|
||||
[path: string]: ResultDirectory
|
||||
};
|
||||
|
||||
type Result = ResultDirectory & {
|
||||
map: ResultDirectories
|
||||
};
|
||||
|
||||
/* EXPORT */
|
||||
|
||||
export type {Callback, Options, ResultDirectory, ResultDirectories, Result};
|
43
ts/tiny-readdir/utils.ts
Normal file
43
ts/tiny-readdir/utils.ts
Normal file
@ -0,0 +1,43 @@
|
||||
|
||||
/* IMPORT */
|
||||
|
||||
import makeNakedPromise from '../promise-make-naked/index.js';
|
||||
import type {Callback} from './types.js';
|
||||
|
||||
/* MAIN */
|
||||
|
||||
const isFunction = ( value: unknown ): value is Function => {
|
||||
|
||||
return ( typeof value === 'function' );
|
||||
|
||||
};
|
||||
|
||||
const makeCounterPromise = (): { promise: Promise<void>, increment: Callback, decrement: Callback } => {
|
||||
|
||||
const {promise, resolve} = makeNakedPromise<void> ();
|
||||
|
||||
let counter = 0;
|
||||
|
||||
const increment = (): void => {
|
||||
|
||||
counter += 1;
|
||||
|
||||
};
|
||||
|
||||
const decrement = (): void => {
|
||||
|
||||
counter -= 1;
|
||||
|
||||
if ( counter ) return;
|
||||
|
||||
resolve ();
|
||||
|
||||
};
|
||||
|
||||
return { promise, increment, decrement };
|
||||
|
||||
};
|
||||
|
||||
/* EXPORT */
|
||||
|
||||
export {isFunction, makeCounterPromise};
|
104
ts/types.ts
Normal file
104
ts/types.ts
Normal file
@ -0,0 +1,104 @@
|
||||
|
||||
/* IMPORT */
|
||||
|
||||
import type {FSWatcher, BigIntStats} from 'node:fs';
|
||||
import type {FSTargetEvent, TargetEvent} from './enums.js';
|
||||
import type WatcherStats from './watcher_stats.js';
|
||||
|
||||
type ResultDirectory = {
|
||||
directories: string[];
|
||||
directoriesNames: Set<string>;
|
||||
directoriesNamesToPaths: Record<string, string[]>;
|
||||
files: string[];
|
||||
filesNames: Set<string>;
|
||||
filesNamesToPaths: Record<string, string[]>;
|
||||
symlinks: string[];
|
||||
symlinksNames: Set<string>;
|
||||
symlinksNamesToPaths: Record<string, string[]>;
|
||||
};
|
||||
type ResultDirectories = {
|
||||
[path: string]: ResultDirectory;
|
||||
};
|
||||
|
||||
/* MAIN */
|
||||
|
||||
type Callback = () => void;
|
||||
|
||||
type Disposer = () => void;
|
||||
|
||||
type Event = [TargetEvent, Path, Path?];
|
||||
|
||||
type FSHandler = ( event?: FSTargetEvent, targetName?: string ) => void;
|
||||
|
||||
type Handler = ( event: TargetEvent, targetPath: Path, targetPathNext?: Path ) => void;
|
||||
|
||||
type HandlerBatched = ( event?: FSTargetEvent, targetPath?: Path, isInitial?: boolean ) => Promise<void>;
|
||||
|
||||
type Ignore = (( targetPath: Path ) => boolean) | RegExp;
|
||||
|
||||
type INO = bigint | number;
|
||||
|
||||
type Path = string;
|
||||
|
||||
type ReaddirMap = ResultDirectories;
|
||||
|
||||
type Stats = BigIntStats;
|
||||
|
||||
type LocksAdd = Map<INO, () => void>;
|
||||
|
||||
type LocksUnlink = Map<INO, () => Path>;
|
||||
|
||||
type LocksPair = {
|
||||
add: LocksAdd,
|
||||
unlink: LocksUnlink
|
||||
};
|
||||
|
||||
type LockConfig = {
|
||||
ino?: INO,
|
||||
targetPath: Path,
|
||||
locks: LocksPair,
|
||||
events: {
|
||||
add: TargetEvent.ADD | TargetEvent.ADD_DIR,
|
||||
change?: TargetEvent.CHANGE,
|
||||
rename: TargetEvent.RENAME | TargetEvent.RENAME_DIR,
|
||||
unlink: TargetEvent.UNLINK | TargetEvent.UNLINK_DIR
|
||||
}
|
||||
};
|
||||
|
||||
type PollerConfig = {
|
||||
options: WatcherOptions,
|
||||
targetPath: Path
|
||||
};
|
||||
|
||||
type SubwatcherConfig = {
|
||||
options: WatcherOptions,
|
||||
targetPath: Path
|
||||
};
|
||||
|
||||
type WatcherConfig = {
|
||||
handler: Handler,
|
||||
watcher: FSWatcher,
|
||||
options: WatcherOptions,
|
||||
folderPath: Path,
|
||||
filePath?: Path
|
||||
};
|
||||
|
||||
type WatcherOptions = {
|
||||
debounce?: number,
|
||||
depth?: number, //FIXME: Not respected when events are detected and native recursion is available, but setting a maximum depth is mostly relevant for the non-native implemention
|
||||
limit?: number, //FIXME: Not respected for newly added directories, but hard to keep track of everything and not has important
|
||||
ignore?: Ignore,
|
||||
ignoreInitial?: boolean,
|
||||
native?: boolean,
|
||||
persistent?: boolean,
|
||||
pollingInterval?: number,
|
||||
pollingTimeout?: number,
|
||||
readdirMap?: ReaddirMap,
|
||||
recursive?: boolean,
|
||||
renameDetection?: boolean,
|
||||
renameTimeout?: number //TODO: Having a timeout for these sorts of things isn't exactly reliable, but what's the better option?
|
||||
};
|
||||
|
||||
/* EXPORT */
|
||||
|
||||
export type {Callback, Disposer, Event, FSHandler, FSWatcher, Handler, HandlerBatched, Ignore, INO, Path, ReaddirMap, Stats, LocksAdd, LocksUnlink, LocksPair, LockConfig, PollerConfig, SubwatcherConfig, WatcherConfig, WatcherOptions, WatcherStats};
|
208
ts/utils.ts
Normal file
208
ts/utils.ts
Normal file
@ -0,0 +1,208 @@
|
||||
|
||||
/* IMPORT */
|
||||
|
||||
import { debounce } from './dettle/index.js';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import sfs from 'stubborn-fs';
|
||||
import readdir from './tiny-readdir/index.js';
|
||||
import {POLLING_TIMEOUT} from './constants.js';
|
||||
import type {Callback, Ignore, ReaddirMap, Stats} from './types.js';
|
||||
|
||||
/* MAIN */
|
||||
|
||||
const Utils = {
|
||||
|
||||
/* LANG API */
|
||||
|
||||
lang: { //TODO: Import all these utilities from "nanodash" instead
|
||||
|
||||
debounce,
|
||||
|
||||
attempt: <T> ( fn: () => T ): T | Error => {
|
||||
|
||||
try {
|
||||
|
||||
return fn ();
|
||||
|
||||
} catch ( error: unknown ) {
|
||||
|
||||
return Utils.lang.castError ( error );
|
||||
|
||||
}
|
||||
|
||||
},
|
||||
|
||||
castArray: <T> ( x: T | T[] ): T[] => {
|
||||
|
||||
return Utils.lang.isArray ( x ) ? x : [x];
|
||||
|
||||
},
|
||||
|
||||
castError: ( exception: unknown ): Error => {
|
||||
|
||||
if ( Utils.lang.isError ( exception ) ) return exception;
|
||||
|
||||
if ( Utils.lang.isString ( exception ) ) return new Error ( exception );
|
||||
|
||||
return new Error ( 'Unknown error' );
|
||||
|
||||
},
|
||||
|
||||
defer: ( callback: Callback ): NodeJS.Timeout => {
|
||||
|
||||
return setTimeout ( callback, 0 );
|
||||
|
||||
},
|
||||
|
||||
isArray: ( value: unknown ): value is unknown[] => {
|
||||
|
||||
return Array.isArray ( value );
|
||||
|
||||
},
|
||||
|
||||
isError: ( value: unknown ): value is Error => {
|
||||
|
||||
return value instanceof Error;
|
||||
|
||||
},
|
||||
|
||||
isFunction: ( value: unknown ): value is Function => {
|
||||
|
||||
return typeof value === 'function';
|
||||
|
||||
},
|
||||
|
||||
isNaN: ( value: unknown ): value is number => {
|
||||
|
||||
return Number.isNaN ( value );
|
||||
|
||||
},
|
||||
|
||||
isNumber: ( value: unknown ): value is number => {
|
||||
|
||||
return typeof value === 'number';
|
||||
|
||||
},
|
||||
|
||||
isPrimitive: ( value: unknown ): value is bigint | symbol | string | number | boolean | null | undefined => {
|
||||
|
||||
if ( value === null ) return true;
|
||||
|
||||
const type = typeof value;
|
||||
|
||||
return type !== 'object' && type !== 'function';
|
||||
|
||||
},
|
||||
|
||||
isShallowEqual: ( x: any, y: any ): boolean => {
|
||||
|
||||
if ( x === y ) return true;
|
||||
|
||||
if ( Utils.lang.isNaN ( x ) ) return Utils.lang.isNaN ( y );
|
||||
|
||||
if ( Utils.lang.isPrimitive ( x ) || Utils.lang.isPrimitive ( y ) ) return x === y;
|
||||
|
||||
for ( const i in x ) if ( !( i in y ) ) return false;
|
||||
|
||||
for ( const i in y ) if ( x[i] !== y[i] ) return false;
|
||||
|
||||
return true;
|
||||
|
||||
},
|
||||
|
||||
isSet: ( value: unknown ): value is Set<unknown> => {
|
||||
|
||||
return value instanceof Set;
|
||||
|
||||
},
|
||||
|
||||
isString: ( value: unknown ): value is string => {
|
||||
|
||||
return typeof value === 'string';
|
||||
|
||||
},
|
||||
|
||||
isUndefined: ( value: unknown ): value is undefined => {
|
||||
|
||||
return value === undefined;
|
||||
|
||||
},
|
||||
|
||||
noop: (): undefined => {
|
||||
|
||||
return;
|
||||
|
||||
},
|
||||
|
||||
uniq: <T> ( arr: T[] ): T[] => {
|
||||
|
||||
if ( arr.length < 2 ) return arr;
|
||||
|
||||
return Array.from ( new Set ( arr ) );
|
||||
|
||||
}
|
||||
|
||||
},
|
||||
|
||||
/* FS API */
|
||||
|
||||
fs: {
|
||||
|
||||
getDepth: ( targetPath: string ): number => {
|
||||
|
||||
return Math.max ( 0, targetPath.split ( path.sep ).length - 1 );
|
||||
|
||||
},
|
||||
|
||||
getRealPath: ( targetPath: string, native?: boolean ): string | undefined => {
|
||||
|
||||
try {
|
||||
|
||||
return native ? fs.realpathSync.native ( targetPath ) : fs.realpathSync ( targetPath );
|
||||
|
||||
} catch {
|
||||
|
||||
return;
|
||||
|
||||
}
|
||||
|
||||
},
|
||||
|
||||
isSubPath: ( targetPath: string, subPath: string ): boolean => {
|
||||
|
||||
return ( subPath.startsWith ( targetPath ) && subPath[targetPath.length] === path.sep && ( subPath.length - targetPath.length ) > path.sep.length );
|
||||
|
||||
},
|
||||
|
||||
poll: ( targetPath: string, timeout: number = POLLING_TIMEOUT ): Promise<Stats | undefined> => {
|
||||
|
||||
return sfs.retry.stat ( timeout )( targetPath, { bigint: true } ).catch ( Utils.lang.noop );
|
||||
|
||||
},
|
||||
|
||||
readdir: async ( rootPath: string, ignore?: Ignore, depth: number = Infinity, limit: number = Infinity, signal?: { aborted: boolean }, readdirMap?: ReaddirMap ): Promise<[string[], string[]]> => {
|
||||
|
||||
if ( readdirMap && depth === 1 && rootPath in readdirMap ) { // Reusing cached data
|
||||
|
||||
const result = readdirMap[rootPath];
|
||||
|
||||
return [result.directories, result.files];
|
||||
|
||||
} else { // Retrieving fresh data
|
||||
|
||||
const result = await readdir ( rootPath, { depth, limit, ignore, signal } );
|
||||
|
||||
return [result.directories, result.files];
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
/* EXPORT */
|
||||
|
||||
export default Utils;
|
655
ts/watcher.ts
Normal file
655
ts/watcher.ts
Normal file
@ -0,0 +1,655 @@
|
||||
|
||||
/* IMPORT */
|
||||
|
||||
import {EventEmitter} from 'node:events';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import {DEPTH, LIMIT, HAS_NATIVE_RECURSION, POLLING_INTERVAL} from './constants.js';
|
||||
import {TargetEvent, WatcherEvent} from './enums.js';
|
||||
import Utils from './utils.js';
|
||||
import WatcherHandler from './watcher_handler.js';
|
||||
import WatcherLocker from './watcher_locker.js';
|
||||
import WatcherPoller from './watcher_poller.js';
|
||||
import type {Callback, Disposer, Handler, Ignore, Path, PollerConfig, SubwatcherConfig, WatcherOptions, WatcherConfig} from './types.js';
|
||||
|
||||
/* MAIN */
|
||||
|
||||
class Watcher extends EventEmitter {
|
||||
|
||||
/* VARIABLES */
|
||||
|
||||
_closed: boolean;
|
||||
_ready: boolean;
|
||||
_closeAborter: AbortController;
|
||||
_closeSignal: { aborted: boolean };
|
||||
_closeWait: Promise<void>;
|
||||
_readyWait: Promise<void>;
|
||||
_locker: WatcherLocker;
|
||||
_roots: Set<Path>;
|
||||
_poller: WatcherPoller;
|
||||
_pollers: Set<PollerConfig>;
|
||||
_subwatchers: Set<SubwatcherConfig>;
|
||||
_watchers: Record<Path, WatcherConfig[]>;
|
||||
_watchersLock: Promise<void>;
|
||||
_watchersRestorable: Record<Path, WatcherConfig>;
|
||||
_watchersRestoreTimeout?: NodeJS.Timeout;
|
||||
|
||||
/* CONSTRUCTOR */
|
||||
|
||||
constructor ( target?: Path[] | Path | Handler, options?: WatcherOptions | Handler, handler?: Handler ) {
|
||||
|
||||
super ();
|
||||
|
||||
this._closed = false;
|
||||
this._ready = false;
|
||||
this._closeAborter = new AbortController ();
|
||||
this._closeSignal = this._closeAborter.signal;
|
||||
this.on ( WatcherEvent.CLOSE, () => this._closeAborter.abort () );
|
||||
this._closeWait = new Promise ( resolve => this.on ( WatcherEvent.CLOSE, resolve ) );
|
||||
this._readyWait = new Promise ( resolve => this.on ( WatcherEvent.READY, resolve ) );
|
||||
this._locker = new WatcherLocker ( this );
|
||||
this._roots = new Set ();
|
||||
this._poller = new WatcherPoller ();
|
||||
this._pollers = new Set ();
|
||||
this._subwatchers = new Set ();
|
||||
this._watchers = {};
|
||||
this._watchersLock = Promise.resolve ();
|
||||
this._watchersRestorable = {};
|
||||
|
||||
this.watch ( target, options, handler );
|
||||
|
||||
}
|
||||
|
||||
/* API */
|
||||
|
||||
isClosed (): boolean {
|
||||
|
||||
return this._closed;
|
||||
|
||||
}
|
||||
|
||||
isIgnored ( targetPath: Path, ignore?: Ignore ): boolean {
|
||||
|
||||
return !!ignore && ( Utils.lang.isFunction ( ignore ) ? !!ignore ( targetPath ) : ignore.test ( targetPath ) );
|
||||
|
||||
}
|
||||
|
||||
isReady (): boolean {
|
||||
|
||||
return this._ready;
|
||||
|
||||
}
|
||||
|
||||
close (): boolean {
|
||||
|
||||
this._locker.reset ();
|
||||
this._poller.reset ();
|
||||
this._roots.clear ();
|
||||
|
||||
this.watchersClose ();
|
||||
|
||||
if ( this.isClosed () ) return false;
|
||||
|
||||
this._closed = true;
|
||||
|
||||
return this.emit ( WatcherEvent.CLOSE );
|
||||
|
||||
}
|
||||
|
||||
error ( exception: unknown ): boolean {
|
||||
|
||||
if ( this.isClosed () ) return false;
|
||||
|
||||
const error = Utils.lang.castError ( exception );
|
||||
|
||||
return this.emit ( WatcherEvent.ERROR, error );
|
||||
|
||||
}
|
||||
|
||||
event ( event: TargetEvent, targetPath: Path, targetPathNext?: Path ): boolean {
|
||||
|
||||
if ( this.isClosed () ) return false;
|
||||
|
||||
this.emit ( WatcherEvent.ALL, event, targetPath, targetPathNext );
|
||||
|
||||
return this.emit ( event, targetPath, targetPathNext );
|
||||
|
||||
}
|
||||
|
||||
ready (): boolean {
|
||||
|
||||
if ( this.isClosed () || this.isReady () ) return false;
|
||||
|
||||
this._ready = true;
|
||||
|
||||
return this.emit ( WatcherEvent.READY );
|
||||
|
||||
}
|
||||
|
||||
pollerExists ( targetPath: Path, options: WatcherOptions ): boolean { //FIXME: This doesn't actually allow for multiple pollers to the same paths, but potentially in the future the same path could be polled with different callbacks to be called, which this doesn't currently allow for
|
||||
|
||||
for ( const poller of this._pollers ) {
|
||||
|
||||
if ( poller.targetPath !== targetPath ) continue;
|
||||
|
||||
if ( !Utils.lang.isShallowEqual ( poller.options, options ) ) continue;
|
||||
|
||||
return true;
|
||||
|
||||
}
|
||||
|
||||
return false;
|
||||
|
||||
}
|
||||
|
||||
subwatcherExists ( targetPath: Path, options: WatcherOptions ): boolean { //FIXME: This doesn't actually allow for multiple subwatchers to the same paths, but potentially in the future the same path could be subwatched with different callbacks to be called, which this doesn't currently allow for
|
||||
|
||||
for ( const subwatcher of this._subwatchers ) {
|
||||
|
||||
if ( subwatcher.targetPath !== targetPath ) continue;
|
||||
|
||||
if ( !Utils.lang.isShallowEqual ( subwatcher.options, options ) ) continue;
|
||||
|
||||
return true;
|
||||
|
||||
}
|
||||
|
||||
return false;
|
||||
|
||||
}
|
||||
|
||||
watchersClose ( folderPath?: Path, filePath?: Path, recursive: boolean = true ): void {
|
||||
|
||||
if ( !folderPath ) {
|
||||
|
||||
for ( const folderPath in this._watchers ) {
|
||||
|
||||
this.watchersClose ( folderPath, filePath, false );
|
||||
|
||||
}
|
||||
|
||||
} else {
|
||||
|
||||
const configs = this._watchers[folderPath];
|
||||
|
||||
if ( configs ) {
|
||||
|
||||
for ( const config of [...configs] ) { // It's important to clone the array, as items will be deleted from it also
|
||||
|
||||
if ( filePath && config.filePath !== filePath ) continue;
|
||||
|
||||
this.watcherClose ( config );
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
if ( recursive ) {
|
||||
|
||||
for ( const folderPathOther in this._watchers ) {
|
||||
|
||||
if ( !Utils.fs.isSubPath ( folderPath, folderPathOther ) ) continue;
|
||||
|
||||
this.watchersClose ( folderPathOther, filePath, false );
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
watchersLock ( callback: Callback ): Promise<void> {
|
||||
|
||||
return this._watchersLock.then ( () => {
|
||||
|
||||
return this._watchersLock = new Promise ( async resolve => {
|
||||
|
||||
await callback ();
|
||||
|
||||
resolve ();
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
watchersRestore (): void {
|
||||
|
||||
delete this._watchersRestoreTimeout;
|
||||
|
||||
const watchers = Object.entries ( this._watchersRestorable );
|
||||
|
||||
this._watchersRestorable = {};
|
||||
|
||||
for ( const [targetPath, config] of watchers ) {
|
||||
|
||||
this.watchPath ( targetPath, config.options, config.handler );
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
async watcherAdd ( config: WatcherConfig, baseWatcherHandler?: WatcherHandler ): Promise<WatcherHandler> {
|
||||
|
||||
const {folderPath} = config;
|
||||
|
||||
const configs = this._watchers[folderPath] = ( this._watchers[folderPath] || [] );
|
||||
|
||||
configs.push ( config );
|
||||
|
||||
const watcherHandler = new WatcherHandler ( this, config, baseWatcherHandler );
|
||||
|
||||
await watcherHandler.init ();
|
||||
|
||||
return watcherHandler;
|
||||
|
||||
}
|
||||
|
||||
watcherClose ( config: WatcherConfig ): void {
|
||||
|
||||
config.watcher.close ();
|
||||
|
||||
const configs = this._watchers[config.folderPath];
|
||||
|
||||
if ( configs ) {
|
||||
|
||||
const index = configs.indexOf ( config );
|
||||
|
||||
configs.splice ( index, 1 );
|
||||
|
||||
if ( !configs.length ) {
|
||||
|
||||
delete this._watchers[config.folderPath];
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
const rootPath = config.filePath || config.folderPath;
|
||||
const isRoot = this._roots.has ( rootPath );
|
||||
|
||||
if ( isRoot ) {
|
||||
|
||||
this._watchersRestorable[rootPath] = config;
|
||||
|
||||
if ( !this._watchersRestoreTimeout ) {
|
||||
|
||||
this._watchersRestoreTimeout = Utils.lang.defer ( () => this.watchersRestore () );
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
watcherExists ( folderPath: Path, options: WatcherOptions, handler: Handler, filePath?: Path ): boolean {
|
||||
|
||||
const configsSibling = this._watchers[folderPath];
|
||||
|
||||
if ( !!configsSibling?.find ( config => config.handler === handler && ( !config.filePath || config.filePath === filePath ) && config.options.ignore === options.ignore && !!config.options.native === !!options.native && ( !options.recursive || config.options.recursive ) ) ) return true;
|
||||
|
||||
let folderAncestorPath = path.dirname ( folderPath );
|
||||
|
||||
for ( let depth = 1; depth < Infinity; depth++ ) {
|
||||
|
||||
const configsAncestor = this._watchers[folderAncestorPath];
|
||||
|
||||
if ( !!configsAncestor?.find ( config => ( depth === 1 || ( config.options.recursive && depth <= ( config.options.depth ?? DEPTH ) ) ) && config.handler === handler && ( !config.filePath || config.filePath === filePath ) && config.options.ignore === options.ignore && !!config.options.native === !!options.native && ( !options.recursive || ( config.options.recursive && ( HAS_NATIVE_RECURSION && config.options.native !== false ) ) ) ) ) return true;
|
||||
|
||||
if ( !HAS_NATIVE_RECURSION ) break; // No other ancestor will possibly be found
|
||||
|
||||
const folderAncestorPathNext = path.dirname ( folderPath );
|
||||
|
||||
if ( folderAncestorPath === folderAncestorPathNext ) break;
|
||||
|
||||
folderAncestorPath = folderAncestorPathNext;
|
||||
|
||||
}
|
||||
|
||||
return false;
|
||||
|
||||
}
|
||||
|
||||
async watchDirectories ( foldersPaths: Path[], options: WatcherOptions, handler: Handler, filePath?: Path, baseWatcherHandler?: WatcherHandler ): Promise<WatcherHandler | undefined> {
|
||||
|
||||
if ( this.isClosed () ) return;
|
||||
|
||||
foldersPaths = Utils.lang.uniq ( foldersPaths ).sort ();
|
||||
|
||||
let watcherHandlerLast: WatcherHandler | undefined;
|
||||
|
||||
for ( const folderPath of foldersPaths ) {
|
||||
|
||||
if ( this.isIgnored ( folderPath, options.ignore ) ) continue;
|
||||
|
||||
if ( this.watcherExists ( folderPath, options, handler, filePath ) ) continue;
|
||||
|
||||
try {
|
||||
|
||||
const watcherOptions = ( !options.recursive || ( HAS_NATIVE_RECURSION && options.native !== false ) ) ? options : { ...options, recursive: false }; // Ensuring recursion is explicitly disabled if not available
|
||||
const watcher = fs.watch ( folderPath, watcherOptions );
|
||||
const watcherConfig: WatcherConfig = { watcher, handler, options, folderPath, filePath };
|
||||
const watcherHandler = watcherHandlerLast = await this.watcherAdd ( watcherConfig, baseWatcherHandler );
|
||||
|
||||
const isRoot = this._roots.has ( filePath || folderPath );
|
||||
|
||||
if ( isRoot ) {
|
||||
|
||||
const parentOptions: WatcherOptions = { ...options, ignoreInitial: true, recursive: false }; // Ensuring only the parent folder is being watched
|
||||
const parentFolderPath = path.dirname ( folderPath );
|
||||
const parentFilePath = folderPath;
|
||||
|
||||
await this.watchDirectories ( [parentFolderPath], parentOptions, handler, parentFilePath, watcherHandler );
|
||||
|
||||
//TODO: Watch parents recursively with the following code, which requires other things to be changed too though
|
||||
|
||||
// while ( true ) {
|
||||
|
||||
// await this.watchDirectories ( [parentFolderPath], parentOptions, handler, parentFilePath, watcherHandler );
|
||||
|
||||
// const parentFolderPathNext = path.dirname ( parentFolderPath );
|
||||
|
||||
// if ( parentFolderPath === parentFolderPathNext ) break;
|
||||
|
||||
// parentFilePath = parentFolderPath;
|
||||
// parentFolderPath = parentFolderPathNext;
|
||||
|
||||
// }
|
||||
|
||||
}
|
||||
|
||||
} catch ( error: unknown ) {
|
||||
|
||||
this.error ( error );
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return watcherHandlerLast;
|
||||
|
||||
}
|
||||
|
||||
async watchDirectory ( folderPath: Path, options: WatcherOptions, handler: Handler, filePath?: Path, baseWatcherHandler?: WatcherHandler ): Promise<void> {
|
||||
|
||||
if ( this.isClosed () ) return;
|
||||
|
||||
if ( this.isIgnored ( folderPath, options.ignore ) ) return;
|
||||
|
||||
if ( !options.recursive || ( HAS_NATIVE_RECURSION && options.native !== false ) ) {
|
||||
|
||||
return this.watchersLock ( () => {
|
||||
|
||||
return this.watchDirectories ( [folderPath], options, handler, filePath, baseWatcherHandler );
|
||||
|
||||
});
|
||||
|
||||
} else {
|
||||
|
||||
options = { ...options, recursive: true }; // Ensuring recursion is explicitly enabled
|
||||
|
||||
const depth = options.depth ?? DEPTH;
|
||||
const limit = options.limit ?? LIMIT;
|
||||
const [folderSubPaths] = await Utils.fs.readdir ( folderPath, options.ignore, depth, limit, this._closeSignal, options.readdirMap );
|
||||
|
||||
return this.watchersLock ( async () => {
|
||||
|
||||
const watcherHandler = await this.watchDirectories ( [folderPath], options, handler, filePath, baseWatcherHandler );
|
||||
|
||||
if ( folderSubPaths.length ) {
|
||||
|
||||
const folderPathDepth = Utils.fs.getDepth ( folderPath );
|
||||
|
||||
for ( const folderSubPath of folderSubPaths ) {
|
||||
|
||||
const folderSubPathDepth = Utils.fs.getDepth ( folderSubPath );
|
||||
const subDepth = Math.max ( 0, depth - ( folderSubPathDepth - folderPathDepth ) );
|
||||
const subOptions = { ...options, depth: subDepth }; // Updating the maximum depth to account for depth of the sub path
|
||||
|
||||
await this.watchDirectories ( [folderSubPath], subOptions, handler, filePath, baseWatcherHandler || watcherHandler );
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
async watchFileOnce ( filePath: Path, options: WatcherOptions, callback: Callback ): Promise<void> {
|
||||
|
||||
if ( this.isClosed () ) return;
|
||||
|
||||
options = { ...options, ignoreInitial: false }; // Ensuring initial events are detected too
|
||||
|
||||
if ( this.subwatcherExists ( filePath, options ) ) return;
|
||||
|
||||
const config: SubwatcherConfig = { targetPath: filePath, options };
|
||||
|
||||
const handler = ( event: TargetEvent, targetPath: Path ) => {
|
||||
if ( targetPath !== filePath ) return;
|
||||
stop ();
|
||||
callback ();
|
||||
};
|
||||
|
||||
const watcher = new Watcher ( handler );
|
||||
|
||||
const start = (): void => {
|
||||
this._subwatchers.add ( config );
|
||||
this.on ( WatcherEvent.CLOSE, stop ); // Ensuring the subwatcher is stopped on close
|
||||
watcher.watchFile ( filePath, options, handler );
|
||||
};
|
||||
|
||||
const stop = (): void => {
|
||||
this._subwatchers.delete ( config );
|
||||
this.removeListener ( WatcherEvent.CLOSE, stop ); // Ensuring there are no leftover listeners
|
||||
watcher.close ();
|
||||
};
|
||||
|
||||
return start ();
|
||||
|
||||
}
|
||||
|
||||
async watchFile ( filePath: Path, options: WatcherOptions, handler: Handler ): Promise<void> {
|
||||
|
||||
if ( this.isClosed () ) return;
|
||||
|
||||
if ( this.isIgnored ( filePath, options.ignore ) ) return;
|
||||
|
||||
options = { ...options, recursive: false }; // Ensuring recursion is explicitly disabled
|
||||
|
||||
const folderPath = path.dirname ( filePath );
|
||||
|
||||
return this.watchDirectory ( folderPath, options, handler, filePath );
|
||||
|
||||
}
|
||||
|
||||
async watchPollingOnce ( targetPath: Path, options: WatcherOptions, callback: Callback ): Promise<void> {
|
||||
|
||||
if ( this.isClosed () ) return;
|
||||
|
||||
let isDone = false;
|
||||
|
||||
const poller = new WatcherPoller ();
|
||||
|
||||
const disposer = await this.watchPolling ( targetPath, options, async () => {
|
||||
|
||||
if ( isDone ) return;
|
||||
|
||||
const events = await poller.update ( targetPath, options.pollingTimeout );
|
||||
|
||||
if ( !events.length ) return; // Nothing actually changed, skipping
|
||||
|
||||
if ( isDone ) return; // Another async callback has done the work already, skipping
|
||||
|
||||
isDone = true;
|
||||
|
||||
disposer ();
|
||||
|
||||
callback ();
|
||||
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
async watchPolling ( targetPath: Path, options: WatcherOptions, callback: Callback ): Promise<Disposer> {
|
||||
|
||||
if ( this.isClosed () ) return Utils.lang.noop;
|
||||
|
||||
if ( this.pollerExists ( targetPath, options ) ) return Utils.lang.noop;
|
||||
|
||||
const watcherOptions = { ...options, interval: options.pollingInterval ?? POLLING_INTERVAL }; // Ensuring a default interval is set
|
||||
|
||||
const config: PollerConfig = { targetPath, options };
|
||||
|
||||
const start = (): void => {
|
||||
this._pollers.add ( config );
|
||||
this.on ( WatcherEvent.CLOSE, stop ); // Ensuring polling is stopped on close
|
||||
fs.watchFile ( targetPath, watcherOptions, callback );
|
||||
};
|
||||
|
||||
const stop = (): void => {
|
||||
this._pollers.delete ( config );
|
||||
this.removeListener ( WatcherEvent.CLOSE, stop ); // Ensuring there are no leftover listeners
|
||||
fs.unwatchFile ( targetPath, callback );
|
||||
};
|
||||
|
||||
Utils.lang.attempt ( start );
|
||||
|
||||
return () => Utils.lang.attempt ( stop );
|
||||
|
||||
}
|
||||
|
||||
async watchUnknownChild ( targetPath: Path, options: WatcherOptions, handler: Handler ): Promise<void> {
|
||||
|
||||
if ( this.isClosed () ) return;
|
||||
|
||||
const watch = () => this.watchPath ( targetPath, options, handler );
|
||||
|
||||
return this.watchFileOnce ( targetPath, options, watch );
|
||||
|
||||
}
|
||||
|
||||
async watchUnknownTarget ( targetPath: Path, options: WatcherOptions, handler: Handler ): Promise<void> {
|
||||
|
||||
if ( this.isClosed () ) return;
|
||||
|
||||
const watch = () => this.watchPath ( targetPath, options, handler );
|
||||
|
||||
return this.watchPollingOnce ( targetPath, options, watch );
|
||||
|
||||
}
|
||||
|
||||
async watchPaths ( targetPaths: Path[], options: WatcherOptions, handler: Handler ): Promise<void> {
|
||||
|
||||
if ( this.isClosed () ) return;
|
||||
|
||||
targetPaths = Utils.lang.uniq ( targetPaths ).sort ();
|
||||
|
||||
const isParallelizable = targetPaths.every ( ( targetPath, index ) => targetPaths.every ( ( t, i ) => i === index || !Utils.fs.isSubPath ( targetPath, t ) ) ); // All paths are about separate subtrees, so we can start watching in parallel safely //TODO: Find parallelizable chunks rather than using an all or nothing approach
|
||||
|
||||
if ( isParallelizable ) { // Watching in parallel
|
||||
|
||||
await Promise.all ( targetPaths.map ( targetPath => {
|
||||
|
||||
return this.watchPath ( targetPath, options, handler );
|
||||
|
||||
}));
|
||||
|
||||
} else { // Watching serially
|
||||
|
||||
for ( const targetPath of targetPaths ) {
|
||||
|
||||
await this.watchPath ( targetPath, options, handler );
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
async watchPath ( targetPath: Path, options: WatcherOptions, handler: Handler ): Promise<void> {
|
||||
|
||||
if ( this.isClosed () ) return;
|
||||
|
||||
targetPath = path.resolve ( targetPath );
|
||||
|
||||
if ( this.isIgnored ( targetPath, options.ignore ) ) return;
|
||||
|
||||
const stats = await Utils.fs.poll ( targetPath, options.pollingTimeout );
|
||||
|
||||
if ( !stats ) {
|
||||
|
||||
const parentPath = path.dirname ( targetPath );
|
||||
const parentStats = await Utils.fs.poll ( parentPath, options.pollingTimeout );
|
||||
|
||||
if ( parentStats?.isDirectory () ) {
|
||||
|
||||
return this.watchUnknownChild ( targetPath, options, handler );
|
||||
|
||||
} else {
|
||||
|
||||
return this.watchUnknownTarget ( targetPath, options, handler );
|
||||
|
||||
}
|
||||
|
||||
} else if ( stats.isFile () ) {
|
||||
|
||||
return this.watchFile ( targetPath, options, handler );
|
||||
|
||||
} else if ( stats.isDirectory () ) {
|
||||
|
||||
return this.watchDirectory ( targetPath, options, handler );
|
||||
|
||||
} else {
|
||||
|
||||
this.error ( `"${targetPath}" is not supported` );
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
async watch ( target?: Path[] | Path | Handler, options?: WatcherOptions | Handler, handler: Handler = Utils.lang.noop ): Promise<void> {
|
||||
|
||||
if ( Utils.lang.isFunction ( target ) ) return this.watch ( [], {}, target );
|
||||
|
||||
if ( Utils.lang.isUndefined ( target ) ) return this.watch ( [], options, handler );
|
||||
|
||||
if ( Utils.lang.isFunction ( options ) ) return this.watch ( target, {}, options );
|
||||
|
||||
if ( Utils.lang.isUndefined ( options ) ) return this.watch ( target, {}, handler );
|
||||
|
||||
if ( this.isClosed () ) return;
|
||||
|
||||
if ( this.isReady () ) options.readdirMap = undefined; // Only usable before initialization
|
||||
|
||||
const targetPaths = Utils.lang.castArray ( target );
|
||||
|
||||
targetPaths.forEach ( targetPath => this._roots.add ( targetPath ) );
|
||||
|
||||
await this.watchPaths ( targetPaths, options, handler );
|
||||
|
||||
if ( this.isClosed () ) return;
|
||||
|
||||
if ( handler !== Utils.lang.noop ) {
|
||||
|
||||
this.on ( WatcherEvent.ALL, handler );
|
||||
|
||||
}
|
||||
|
||||
options.readdirMap = undefined; // Only usable before initialization
|
||||
|
||||
this.ready ();
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/* EXPORT */
|
||||
|
||||
export default Watcher;
|
427
ts/watcher_handler.ts
Normal file
427
ts/watcher_handler.ts
Normal file
@ -0,0 +1,427 @@
|
||||
|
||||
/* 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;
|
202
ts/watcher_locker.ts
Normal file
202
ts/watcher_locker.ts
Normal file
@ -0,0 +1,202 @@
|
||||
|
||||
/* IMPORT */
|
||||
|
||||
import {RENAME_TIMEOUT} from './constants.js';
|
||||
import {FileType, TargetEvent} from './enums.js';
|
||||
import Utils from './utils.js';
|
||||
import WatcherLocksResolver from './watcher_locks_resolver.js';
|
||||
import type Watcher from './watcher.js';
|
||||
import type {Path, LocksAdd, LocksUnlink, LocksPair, LockConfig} from './types.js';
|
||||
|
||||
/* MAIN */
|
||||
|
||||
//TODO: Use a better name for this thing, maybe "RenameDetector"
|
||||
|
||||
class WatcherLocker {
|
||||
|
||||
/* VARIABLES */
|
||||
|
||||
_locksAdd!: LocksAdd;
|
||||
_locksAddDir!: LocksAdd;
|
||||
_locksUnlink!: LocksUnlink;
|
||||
_locksUnlinkDir!: LocksUnlink;
|
||||
_locksDir!: LocksPair;
|
||||
_locksFile!: LocksPair;
|
||||
_watcher: Watcher;
|
||||
|
||||
static DIR_EVENTS = <const> {
|
||||
add: TargetEvent.ADD_DIR,
|
||||
rename: TargetEvent.RENAME_DIR,
|
||||
unlink: TargetEvent.UNLINK_DIR
|
||||
};
|
||||
|
||||
static FILE_EVENTS = <const> {
|
||||
add: TargetEvent.ADD,
|
||||
change: TargetEvent.CHANGE,
|
||||
rename: TargetEvent.RENAME,
|
||||
unlink: TargetEvent.UNLINK
|
||||
};
|
||||
|
||||
/* CONSTRUCTOR */
|
||||
|
||||
constructor ( watcher: Watcher ) {
|
||||
|
||||
this._watcher = watcher;
|
||||
|
||||
this.reset ();
|
||||
|
||||
}
|
||||
|
||||
/* API */
|
||||
|
||||
getLockAdd ( config: LockConfig, timeout: number = RENAME_TIMEOUT ): void {
|
||||
|
||||
const {ino, targetPath, events, locks} = config;
|
||||
|
||||
const emit = (): void => {
|
||||
const otherPath = this._watcher._poller.paths.find ( ino || -1, path => path !== targetPath ); // Maybe this is actually a rename in a case-insensitive filesystem
|
||||
if ( otherPath && otherPath !== targetPath ) {
|
||||
if ( Utils.fs.getRealPath ( targetPath, true ) === otherPath ) return; //TODO: This seems a little too special-casey
|
||||
this._watcher.event ( events.rename, otherPath, targetPath );
|
||||
} else {
|
||||
this._watcher.event ( events.add, targetPath );
|
||||
}
|
||||
};
|
||||
|
||||
if ( !ino ) return emit ();
|
||||
|
||||
const cleanup = (): void => {
|
||||
locks.add.delete ( ino );
|
||||
WatcherLocksResolver.remove ( free );
|
||||
};
|
||||
|
||||
const free = (): void => {
|
||||
cleanup ();
|
||||
emit ();
|
||||
};
|
||||
|
||||
WatcherLocksResolver.add ( free, timeout );
|
||||
|
||||
const resolve = (): void => {
|
||||
const unlink = locks.unlink.get ( ino );
|
||||
if ( !unlink ) return; // No matching "unlink" lock found, skipping
|
||||
cleanup ();
|
||||
const targetPathPrev = unlink ();
|
||||
if ( targetPath === targetPathPrev ) {
|
||||
if ( events.change ) {
|
||||
if ( this._watcher._poller.stats.has ( targetPath ) ) {
|
||||
this._watcher.event ( events.change, targetPath );
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this._watcher.event ( events.rename, targetPathPrev, targetPath );
|
||||
}
|
||||
};
|
||||
|
||||
locks.add.set ( ino, resolve );
|
||||
|
||||
resolve ();
|
||||
|
||||
}
|
||||
|
||||
getLockUnlink ( config: LockConfig, timeout: number = RENAME_TIMEOUT ): void {
|
||||
|
||||
const {ino, targetPath, events, locks} = config;
|
||||
|
||||
const emit = (): void => {
|
||||
this._watcher.event ( events.unlink, targetPath );
|
||||
};
|
||||
|
||||
if ( !ino ) return emit ();
|
||||
|
||||
const cleanup = (): void => {
|
||||
locks.unlink.delete ( ino );
|
||||
WatcherLocksResolver.remove ( free );
|
||||
};
|
||||
|
||||
const free = (): void => {
|
||||
cleanup ();
|
||||
emit ();
|
||||
};
|
||||
|
||||
WatcherLocksResolver.add ( free, timeout );
|
||||
|
||||
const overridden = (): Path => {
|
||||
cleanup ();
|
||||
return targetPath;
|
||||
};
|
||||
|
||||
locks.unlink.set ( ino, overridden );
|
||||
|
||||
locks.add.get ( ino )?.();
|
||||
|
||||
}
|
||||
|
||||
getLockTargetAdd ( targetPath: Path, timeout?: number ): void {
|
||||
|
||||
const ino = this._watcher._poller.getIno ( targetPath, TargetEvent.ADD, FileType.FILE );
|
||||
|
||||
return this.getLockAdd ({
|
||||
ino,
|
||||
targetPath,
|
||||
events: WatcherLocker.FILE_EVENTS,
|
||||
locks: this._locksFile
|
||||
}, timeout );
|
||||
|
||||
}
|
||||
|
||||
getLockTargetAddDir ( targetPath: Path, timeout?: number ): void {
|
||||
|
||||
const ino = this._watcher._poller.getIno ( targetPath, TargetEvent.ADD_DIR, FileType.DIR );
|
||||
|
||||
return this.getLockAdd ({
|
||||
ino,
|
||||
targetPath,
|
||||
events: WatcherLocker.DIR_EVENTS,
|
||||
locks: this._locksDir
|
||||
}, timeout );
|
||||
|
||||
}
|
||||
|
||||
getLockTargetUnlink ( targetPath: Path, timeout?: number ): void {
|
||||
|
||||
const ino = this._watcher._poller.getIno ( targetPath, TargetEvent.UNLINK, FileType.FILE );
|
||||
|
||||
return this.getLockUnlink ({
|
||||
ino,
|
||||
targetPath,
|
||||
events: WatcherLocker.FILE_EVENTS,
|
||||
locks: this._locksFile
|
||||
}, timeout );
|
||||
|
||||
}
|
||||
|
||||
getLockTargetUnlinkDir ( targetPath: Path, timeout?: number ): void {
|
||||
|
||||
const ino = this._watcher._poller.getIno ( targetPath, TargetEvent.UNLINK_DIR, FileType.DIR );
|
||||
|
||||
return this.getLockUnlink ({
|
||||
ino,
|
||||
targetPath,
|
||||
events: WatcherLocker.DIR_EVENTS,
|
||||
locks: this._locksDir
|
||||
}, timeout );
|
||||
|
||||
}
|
||||
|
||||
reset (): void {
|
||||
|
||||
this._locksAdd = new Map ();
|
||||
this._locksAddDir = new Map ();
|
||||
this._locksUnlink = new Map ();
|
||||
this._locksUnlinkDir = new Map ();
|
||||
this._locksDir = { add: this._locksAddDir, unlink: this._locksUnlinkDir };
|
||||
this._locksFile = { add: this._locksAdd, unlink: this._locksUnlink };
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/* EXPORT */
|
||||
|
||||
export default WatcherLocker;
|
73
ts/watcher_locks_resolver.ts
Normal file
73
ts/watcher_locks_resolver.ts
Normal file
@ -0,0 +1,73 @@
|
||||
|
||||
/* MAIN */
|
||||
|
||||
// Registering a single interval scales much better than registering N timeouts
|
||||
// Timeouts are respected within the interval margin
|
||||
|
||||
const WatcherLocksResolver = {
|
||||
|
||||
/* VARIABLES */
|
||||
|
||||
interval: 100,
|
||||
intervalId: undefined as NodeJS.Timeout | undefined,
|
||||
fns: new Map<Function, number> (),
|
||||
|
||||
/* LIFECYCLE API */
|
||||
|
||||
init: (): void => {
|
||||
|
||||
if ( WatcherLocksResolver.intervalId ) return;
|
||||
|
||||
WatcherLocksResolver.intervalId = setInterval ( WatcherLocksResolver.resolve, WatcherLocksResolver.interval );
|
||||
|
||||
},
|
||||
|
||||
reset: (): void => {
|
||||
|
||||
if ( !WatcherLocksResolver.intervalId ) return;
|
||||
|
||||
clearInterval ( WatcherLocksResolver.intervalId );
|
||||
|
||||
delete WatcherLocksResolver.intervalId;
|
||||
|
||||
},
|
||||
|
||||
/* API */
|
||||
|
||||
add: ( fn: Function, timeout: number ): void => {
|
||||
|
||||
WatcherLocksResolver.fns.set ( fn, Date.now () + timeout );
|
||||
|
||||
WatcherLocksResolver.init ();
|
||||
|
||||
},
|
||||
|
||||
remove: ( fn: Function ): void => {
|
||||
|
||||
WatcherLocksResolver.fns.delete ( fn );
|
||||
|
||||
},
|
||||
|
||||
resolve: (): void => {
|
||||
|
||||
if ( !WatcherLocksResolver.fns.size ) return WatcherLocksResolver.reset ();
|
||||
|
||||
const now = Date.now ();
|
||||
|
||||
for ( const [fn, timestamp] of WatcherLocksResolver.fns ) {
|
||||
|
||||
if ( timestamp >= now ) continue; // We should still wait some more for this
|
||||
|
||||
WatcherLocksResolver.remove ( fn );
|
||||
|
||||
fn ();
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
/* EXPORT */
|
||||
|
||||
export default WatcherLocksResolver;
|
193
ts/watcher_poller.ts
Normal file
193
ts/watcher_poller.ts
Normal file
@ -0,0 +1,193 @@
|
||||
|
||||
/* IMPORT */
|
||||
|
||||
import {FileType, TargetEvent} from './enums.js';
|
||||
import LazyMapSet from './lazy_map_set.js';
|
||||
import Utils from './utils.js';
|
||||
import WatcherStats from './watcher_stats.js';
|
||||
import type {INO, Path} from './types.js';
|
||||
|
||||
/* MAIN */
|
||||
|
||||
class WatcherPoller {
|
||||
|
||||
/* VARIABLES */
|
||||
|
||||
inos: Partial<Record<TargetEvent, Record<Path, [INO, FileType]>>> = {};
|
||||
paths: LazyMapSet<INO, Path> = new LazyMapSet ();
|
||||
stats: Map<Path, WatcherStats> = new Map ();
|
||||
|
||||
/* API */
|
||||
|
||||
getIno ( targetPath: Path, event: TargetEvent, type?: FileType ): INO | undefined {
|
||||
|
||||
const inos = this.inos[event];
|
||||
|
||||
if ( !inos ) return;
|
||||
|
||||
const ino = inos[targetPath];
|
||||
|
||||
if ( !ino ) return;
|
||||
|
||||
if ( type && ino[1] !== type ) return;
|
||||
|
||||
return ino[0];
|
||||
|
||||
}
|
||||
|
||||
getStats ( targetPath: Path ): WatcherStats | undefined {
|
||||
|
||||
return this.stats.get ( targetPath );
|
||||
|
||||
}
|
||||
|
||||
async poll ( targetPath: Path, timeout?: number ): Promise<WatcherStats | undefined> {
|
||||
|
||||
const stats = await Utils.fs.poll ( targetPath, timeout );
|
||||
|
||||
if ( !stats ) return;
|
||||
|
||||
const isSupported = stats.isFile () || stats.isDirectory ();
|
||||
|
||||
if ( !isSupported ) return;
|
||||
|
||||
return new WatcherStats ( stats );
|
||||
|
||||
}
|
||||
|
||||
reset (): void {
|
||||
|
||||
this.inos = {};
|
||||
this.paths = new LazyMapSet ();
|
||||
this.stats = new Map ();
|
||||
|
||||
}
|
||||
|
||||
async update ( targetPath: Path, timeout?: number ): Promise<TargetEvent[]> {
|
||||
|
||||
const prev = this.getStats ( targetPath );
|
||||
const next = await this.poll ( targetPath, timeout );
|
||||
|
||||
this.updateStats ( targetPath, next );
|
||||
|
||||
if ( !prev && next ) {
|
||||
|
||||
if ( next.isFile () ) {
|
||||
|
||||
this.updateIno ( targetPath, TargetEvent.ADD, next );
|
||||
|
||||
return [TargetEvent.ADD];
|
||||
|
||||
}
|
||||
|
||||
if ( next.isDirectory () ) {
|
||||
|
||||
this.updateIno ( targetPath, TargetEvent.ADD_DIR, next );
|
||||
|
||||
return [TargetEvent.ADD_DIR];
|
||||
|
||||
}
|
||||
|
||||
} else if ( prev && !next ) {
|
||||
|
||||
if ( prev.isFile () ) {
|
||||
|
||||
this.updateIno ( targetPath, TargetEvent.UNLINK, prev );
|
||||
|
||||
return [TargetEvent.UNLINK];
|
||||
|
||||
}
|
||||
|
||||
if ( prev.isDirectory () ) {
|
||||
|
||||
this.updateIno ( targetPath, TargetEvent.UNLINK_DIR, prev );
|
||||
|
||||
return [TargetEvent.UNLINK_DIR];
|
||||
|
||||
}
|
||||
|
||||
} else if ( prev && next ) {
|
||||
|
||||
if ( prev.isFile () ) {
|
||||
|
||||
if ( next.isFile () ) {
|
||||
|
||||
if ( prev.ino === next.ino && !prev.size && !next.size ) return []; // Same path, same content and same file, nothing actually changed
|
||||
|
||||
this.updateIno ( targetPath, TargetEvent.CHANGE, next );
|
||||
|
||||
return [TargetEvent.CHANGE];
|
||||
|
||||
}
|
||||
|
||||
if ( next.isDirectory () ) {
|
||||
|
||||
this.updateIno ( targetPath, TargetEvent.UNLINK, prev );
|
||||
this.updateIno ( targetPath, TargetEvent.ADD_DIR, next );
|
||||
|
||||
return [TargetEvent.UNLINK, TargetEvent.ADD_DIR];
|
||||
|
||||
}
|
||||
|
||||
} else if ( prev.isDirectory () ) {
|
||||
|
||||
if ( next.isFile () ) {
|
||||
|
||||
this.updateIno ( targetPath, TargetEvent.UNLINK_DIR, prev );
|
||||
this.updateIno ( targetPath, TargetEvent.ADD, next );
|
||||
|
||||
return [TargetEvent.UNLINK_DIR, TargetEvent.ADD];
|
||||
|
||||
}
|
||||
|
||||
if ( next.isDirectory () ) {
|
||||
|
||||
if ( prev.ino === next.ino ) return []; // Same path and same directory, nothing actually changed
|
||||
|
||||
this.updateIno ( targetPath, TargetEvent.UNLINK_DIR, prev );
|
||||
this.updateIno ( targetPath, TargetEvent.ADD_DIR, next );
|
||||
|
||||
return [TargetEvent.UNLINK_DIR, TargetEvent.ADD_DIR];
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return [];
|
||||
|
||||
}
|
||||
|
||||
updateIno ( targetPath: Path, event: TargetEvent, stats: WatcherStats ): void {
|
||||
|
||||
const inos = this.inos[event] = this.inos[event] || ( this.inos[event] = {} );
|
||||
const type = stats.isFile () ? FileType.FILE : FileType.DIR;
|
||||
|
||||
inos[targetPath] = [stats.ino, type];
|
||||
|
||||
}
|
||||
|
||||
updateStats ( targetPath: Path, stats?: WatcherStats ): void {
|
||||
|
||||
if ( stats ) {
|
||||
|
||||
this.paths.set ( stats.ino, targetPath );
|
||||
this.stats.set ( targetPath, stats );
|
||||
|
||||
} else {
|
||||
|
||||
const ino = this.stats.get ( targetPath )?.ino || -1;
|
||||
|
||||
this.paths.delete ( ino, targetPath );
|
||||
this.stats.delete ( targetPath );
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/* EXPORT */
|
||||
|
||||
export default WatcherPoller;
|
64
ts/watcher_stats.ts
Normal file
64
ts/watcher_stats.ts
Normal file
@ -0,0 +1,64 @@
|
||||
|
||||
/* IMPORT */
|
||||
|
||||
import type {INO, Stats} from './types.js';
|
||||
|
||||
/* MAIN */
|
||||
|
||||
// An more memory-efficient representation of the useful subset of stats objects
|
||||
|
||||
class WatcherStats {
|
||||
|
||||
/* VARIABLES */
|
||||
|
||||
ino: INO;
|
||||
size: number;
|
||||
atimeMs: number;
|
||||
mtimeMs: number;
|
||||
ctimeMs: number;
|
||||
birthtimeMs: number;
|
||||
_isFile: boolean;
|
||||
_isDirectory: boolean;
|
||||
_isSymbolicLink: boolean;
|
||||
|
||||
/* CONSTRUCTOR */
|
||||
|
||||
constructor ( stats: Stats ) {
|
||||
|
||||
this.ino = ( stats.ino <= Number.MAX_SAFE_INTEGER ) ? Number ( stats.ino ) : stats.ino;
|
||||
this.size = Number ( stats.size );
|
||||
this.atimeMs = Number ( stats.atimeMs );
|
||||
this.mtimeMs = Number ( stats.mtimeMs );
|
||||
this.ctimeMs = Number ( stats.ctimeMs );
|
||||
this.birthtimeMs = Number ( stats.birthtimeMs );
|
||||
this._isFile = stats.isFile ();
|
||||
this._isDirectory = stats.isDirectory ();
|
||||
this._isSymbolicLink = stats.isSymbolicLink ();
|
||||
|
||||
}
|
||||
|
||||
/* API */
|
||||
|
||||
isFile (): boolean {
|
||||
|
||||
return this._isFile;
|
||||
|
||||
}
|
||||
|
||||
isDirectory (): boolean {
|
||||
|
||||
return this._isDirectory;
|
||||
|
||||
}
|
||||
|
||||
isSymbolicLink (): boolean {
|
||||
|
||||
return this._isSymbolicLink;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/* EXPORT */
|
||||
|
||||
export default WatcherStats;
|
Reference in New Issue
Block a user