OverviewFilesCommitsIssuesDocsReleasesPeers

codetree / lib / fs-watcher.js

80 lines · javascript ref:
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
    }
  }
}