import { watch, statSync } from 'fs'
import { EventEmitter } from 'events'
import { resolve, relative, join } from 'path'
/**
* Wraps fs.watch for a project root and emits normalized `change` events.
*
* Honors the ignore matcher — hidden files don't produce events.
*
* const w = new FsWatcher(rootDir, ignore)
* w.on('change', path => ...)
* w.on('remove', path => ...)
* w.close()
*
* There are no timers here. bare-fs.watch is inotify-backed on Linux; every
* event is a real filesystem push from the kernel.
*/
export class FsWatcher extends EventEmitter {
constructor (rootDir, ignore, opts = {}) {
super()
this.rootDir = resolve(rootDir)
this.ignore = ignore || (() => false)
this._debounce = new Map() // path -> timer? No — we batch via a set.
this._pending = new Set() // coalesce rapid-fire events
this._flushScheduled = false
this._closed = false
try {
this._handle = watch(this.rootDir, { recursive: true }, (eventType, filename) => {
if (this._closed || !filename) return
// fs.watch gives a path relative to the watched root. Normalize to absolute.
const abs = join(this.rootDir, filename)
if (this.ignore(abs)) return
this._enqueue(abs)
})
// Errors from the underlying watcher (ENOSPC mid-stream, EBADF, etc.)
// must be caught — without a listener Node crashes the process.
this._handle.on('error', err => {
// Mark closed so we stop trying to enqueue events
this._closed = true
queueMicrotask(() => this.emit('error', err))
})
} catch (err) {
// Watching failed at construction (platform limit, permission, etc.). Emit error
// instead of crashing — server can still serve the initial tree.
queueMicrotask(() => this.emit('error', err))
}
}
// Coalesce bursts of events on the same path into a single `change` emission.
// Many editors rewrite files via temp + rename, which fires 2-3 events in
// the same tick. Batching with queueMicrotask merges them without a timer.
_enqueue (abs) {
this._pending.add(abs)
if (this._flushScheduled) return
this._flushScheduled = true
queueMicrotask(() => this._flush())
}
_flush () {
this._flushScheduled = false
for (const abs of this._pending) {
let exists = true
try { statSync(abs) } catch { exists = false }
const relPath = relative(this.rootDir, abs)
if (exists) this.emit('change', { absPath: abs, relPath })
else this.emit('remove', { absPath: abs, relPath })
}
this._pending.clear()
}
close () {
this._closed = true
if (this._handle) {
try { this._handle.close() } catch {}
this._handle = null
}
}
}