import assert from "assert" ; import fs from "fs" ; import path from "path" ; import { debuglog } from "util" ; const log = debuglog ( "mf-watch" ) ; function withinDir ( dir , file ) { const rel = path . relative ( dir , file ) ; return ! ! rel && ! rel . startsWith ( ".." ) && ! path . isAbsolute ( rel ) ; } function walkDirs ( root , callback ) { callback ( root ) ; for ( const name of fs . readdirSync ( root ) ) { const filePath = path . join ( root , name ) ; if ( ! fs . statSync ( filePath ) . isDirectory ( ) ) return ; walkDirs ( filePath , callback ) ; } } export type WatcherCallback = ( path : string ) => void ; export interface WatcherOptions { debounce ? : number ; pollInterval ? : number ; createPollInterval ? : number ; forceRecursive ? : boolean ; } class PathWatcher { private watchFileListener ? : ( curr : fs . Stats , prev : fs . Stats ) => void ; private watcher ? : fs . FSWatcher ; private watchers ? : Map < string , fs . FSWatcher > ; private lastMtimes ? : Map < string , number > ; constructor ( private options , private filePath , private callback ) { } private get watching ( ) : boolean { return ! ! ( this . watchFileListener || this . watcher || this . watchers || this . lastMtimes ) ; } private startCreateWatcher ( ) { assert ( ! this . watching ) ; log ( `${ this . filePath }: polling for create...` ) ; this . watchFileListener = ( curr ) => { if ( curr . mtimeMs === 0 ) return ; log ( `${ this . filePath }: created, tidying up temporary watcher...` ) ; fs . unwatchFile ( this . filePath , this . watchFileListener ) ; this . watchFileListener = undefined ; this . callback ( ) ; void this . start ( ) ; } ; fs . watchFile ( this . filePath , { interval : this . options . createPollInterval } , this . watchFileListener ) ; } private startPollingWatcher ( ) { assert ( ! this . watching ) ; log ( `${ this . filePath }: polling...` ) ; this . watchFileListener = ( curr , prev ) => { log ( `${ this . filePath }: ${ prev . mtimeMs } -> ${ curr . mtimeMs }` ) ; if ( curr . mtimeMs === 0 ) { this . callback ( ) ; this . dispose ( ) ; this . startCreateWatcher ( ) ; } else if ( curr . mtimeMs !== prev . mtimeMs ) { this . callback ( ) ; } } ; fs . watchFile ( this . filePath , { interval : this . options . pollInterval } , this . watchFileListener ) ; } private listener : fs . WatchListener < string > = ( event , fileName ) => { log ( `${ this . filePath }: ${ event }: ${ fileName }` ) ; if ( fileName ) { try { const resolved = path . resolve ( this . filePath , fileName ) ; const mtime = fs . statSync ( resolved ) . mtimeMs ; const previousMtime = this . lastMtimes ?. get ( resolved ) ; if ( previousMtime === mtime ) { log ( `${ this . filePath }: ${ resolved }: ignored spurious event` ) ; return ; } this . lastMtimes ?. set ( resolved , mtime ) ; } catch { } } this . callback ( ) ; if ( ! fs . existsSync ( this . filePath ) ) { this . dispose ( ) ; this . startCreateWatcher ( ) ; } } ; private startDeletedWatcher ( ) { this . watchFileListener = ( curr ) => { if ( curr . mtimeMs === 0 ) { this . callback ( ) ; this . dispose ( ) ; this . startCreateWatcher ( ) ; } } ; fs . watchFile ( this . filePath , { interval : this . options . pollInterval } , this . watchFileListener ) ; } private startPlatformRecursiveWatcher ( ) { assert ( ! this . watching ) ; log ( `${ this . filePath }: recursively watching with platform...` ) ; this . lastMtimes = new Map < string , number > ( ) ; this . watcher = fs . watch ( this . filePath , { recursive : true } , this . listener ) ; this . startDeletedWatcher ( ) ; } private startRecursiveWatcher ( ) { assert ( ! this . watching ) ; log ( `${ this . filePath }: recursively watching...` ) ; const watchers = ( this . watchers = new Map < string , fs . FSWatcher > ( ) ) ; this . lastMtimes = new Map < string , number > ( ) ; const update = ( dir , event , fileName ) => { let dirIsDirectory = false ; try { dirIsDirectory = fs . statSync ( dir ) . isDirectory ( ) ; } catch { } if ( ! dirIsDirectory ) { log ( `${ this . filePath }: ${ dir } is no longer a directory, resetting...` ) ; this . callback ( ) ; this . dispose ( ) ; this . start ( ) ; return ; } const filePath = path . join ( dir , fileName ) ; this . listener ( event , filePath ) ; try { if ( fs . statSync ( filePath ) . isDirectory ( ) ) { walkDirs ( filePath , walkCallback ) ; } } catch ( e ) { if ( e . code !== "ENOENT" ) throw e ; for ( const [ watchedPath , watcher ] of watchers . entries ( ) ) { if ( filePath === watchedPath || withinDir ( filePath , watchedPath ) ) { watcher . close ( ) ; watchers . delete ( watchedPath ) ; } } } log ( `${ this . filePath }: watching ${ [ ... watchers . keys ( ) ] . join ( "," ) }` ) ; } ; const walkCallback = ( dir ) => { if ( ! watchers . has ( dir ) ) { watchers . set ( dir , fs . watch ( dir , update . bind ( this , dir ) ) ) ; } } ; try { walkDirs ( this . filePath , walkCallback ) ; this . startDeletedWatcher ( ) ; } catch ( e ) { if ( e . code !== "ENOENT" ) throw e ; this . dispose ( ) ; this . startCreateWatcher ( ) ; } } start ( ) { try { if ( fs . statSync ( this . filePath ) . isDirectory ( ) ) { if ( this . options . forceRecursive ) { return this . startRecursiveWatcher ( ) ; } else { return this . startPlatformRecursiveWatcher ( ) ; } } else { return this . startPollingWatcher ( ) ; } } catch ( e ) { this . dispose ( ) ; if ( e . code === "ENOENT" ) { return this . startCreateWatcher ( ) ; } if ( e . code === "ERR_FEATURE_UNAVAILABLE_ON_PLATFORM" ) { return this . startRecursiveWatcher ( ) ; } throw e ; } } dispose ( ) { log ( `${ this . filePath }: disposing...` ) ; if ( this . watchFileListener ) { fs . unwatchFile ( this . filePath , this . watchFileListener ) ; this . watchFileListener = undefined ; } this . watcher ?. close ( ) ; this . watcher = undefined ; if ( this . watchers ) { for ( const watcher of this . watchers . values ( ) ) watcher . close ( ) ; this . watchers = undefined ; } this . lastMtimes = undefined ; assert ( ! this . watching ) ; } } export class Watcher { readonly #watchers = new Map < string , PathWatcher > ( ) ; readonly #callback : WatcherCallback ; readonly #options : Required < WatcherOptions > ; constructor ( callback , options ? ) { this . #callback = callback ; this . #options = { debounce : options ?. debounce ?? 50 , pollInterval : options ?. pollInterval ?? 250 , createPollInterval : options ?. createPollInterval ?? 1000 , forceRecursive : options ?. forceRecursive ?? false , } ; } watch ( paths ) { if ( typeof paths === "string" ) paths = [ paths ] ; for ( const rawPath of paths ) { const resolved = path . resolve ( rawPath ) ; if ( this . #watchers . has ( resolved ) ) { log ( `${ resolved }: already watching` ) ; continue ; } log ( `${ resolved }: watching...` ) ; let debounceHandle ; const callback = ( ) => { clearTimeout ( debounceHandle ) ; debounceHandle = setTimeout ( this . #callback , this . #options . debounce , resolved ) ; } ; const watcher = new PathWatcher ( this . #options , resolved , callback ) ; this . #watchers . set ( resolved , watcher ) ; watcher . start ( ) ; } } unwatch ( paths ) { if ( typeof paths === "string" ) paths = [ paths ] ; for ( const rawPath of paths ) { const resolved = path . resolve ( rawPath ) ; log ( `${ resolved }: unwatching...` ) ; this . #watchers . get ( resolved ) ?. dispose ( ) ; this . #watchers . delete ( resolved ) ; } } dispose ( ) { for ( const watcher of this . #watchers . values ( ) ) watcher . dispose ( ) ; this . #watchers . clear ( ) ; } }