Skip to content

RFC: Integrate watch()/watchFile() using notify-rs #24

@CoderSerio

Description

@CoderSerio

RFC: Integrate watch() / watchFile() into @rush-fs/core

Motivation

@rush-fs/core already provides high-performance replacements for readdir, readFile, writeFile, glob, cp, rm, etc. But it lacks fs.watch() / fs.watchFile() — users still need to rely on chokidar or other third-party libraries for file watching.

Node.js's native fs.watch() has well-known issues (documented by chokidar):

  • macOS doesn't report filenames — FSEvents batch pushes often omit path info
  • Duplicate events — same change triggers multiple notifications
  • rename is ambiguous — create, delete, and rename all report as rename, requiring manual stat checks
  • Inconsistent recursive support — only macOS supports it natively
  • Poor atomic write handling — editor temp-file + rename patterns produce false unlink→add events

Chokidar solves these in JavaScript, but the cost is single-threaded CPU overhead.

Proposal

Integrate watch() and watchFile() into @rush-fs/core using Rust + notify-rs as the backend, with API aligned to fs.watch() / fs.watchFile(). Rush-FS extensions (precise events, debounce, gitignore filtering) are all opt-in — default behavior matches native fs.watch() for zero migration cost.

Architecture

Node.js/TypeScript
  ↓ (NAPI ThreadsafeFunction)
Rust Event Processing
  ├─ Debounce (notify-debouncer-mini)
  ├─ Dedup (inode-based, file-id crate)
  ├─ Event normalization (rename → add/change/unlink when preciseEvents=true)
  └─ Gitignore filter (optional, ignore-files crate)
  ↓
notify-rs (backend)
  ├─ macOS: FSEvents
  ├─ Linux: inotify
  ├─ Windows: ReadDirectoryChangesW
  └─ All: Polling fallback

API Design

watch() — aligned with fs.watch()

import { watch } from '@rush-fs/core'

// Native API compatible
const watcher = watch('src/', { recursive: true }, (eventType, filename) => {
  console.log(eventType, filename)
  // eventType: 'rename' | 'change'  (native compatible)
})

// Rush-FS extensions (opt-in)
const watcher = watch('src/', {
  recursive: true,
  debounce: 100,         // event coalesce window (ms), default 0
  preciseEvents: true,   // rename → add/change/unlink + addDir/unlinkDir
  gitignore: true,       // auto-ignore .gitignore/.ignore paths
}, (eventType, filename) => {
  // preciseEvents enabled:
  // eventType: 'add' | 'change' | 'unlink' | 'addDir' | 'unlinkDir' | 'rename'
})

watcher.close()

watchFile() — aligned with fs.watchFile()

import { watchFile, unwatchFile } from '@rush-fs/core'

watchFile('file.txt', { interval: 1000 }, (curr, prev) => {
  console.log('changed', curr.mtimeMs, prev.mtimeMs)
})

unwatchFile('file.txt')

Rush-FS implementation prioritizes notify-rs native notifications over stat polling, falling back to polling only when native APIs are unavailable (network filesystems, etc.).

Rush-FS Extension Options

Option Default Description
debounce 0 (off) Event coalesce window (ms). Set to ~100 to merge editor save events
preciseEvents false Split rename into add/change/unlink + addDir/unlinkDir
gitignore false Auto-parse .gitignore/.ignore to skip matching paths

Design principle: All extensions are off by default. Rush-FS watch() defaults to native fs.watch() behavior for zero migration cost.

Core Dependencies

Crate Purpose
notify Cross-platform file notification (main backend)
notify-debouncer-mini Event coalescing/debouncing
notify-types Event type definitions
file-id Inode-based file identification (dedup)
napi + napi-derive NAPI-RS bindings
ignore-files (watchexec) .gitignore/.ignore parsing (optional, Phase 2)

Milestones

Phase Content Estimate
P0 Rust basic binding: watch() + close() 2-3 days
P1 Event transformation (native compatible mode) 1-2 days
P2 preciseEvents extension 1 day
P3 watchFile() + unwatchFile() 2 days
P4 debounce option 1 day
P5 TS types + docs + benchmark 2 days

Total: ~1-2 weeks for MVP

Compatibility Matrix

Scenario fs.watch() Rush-FS default Rush-FS + preciseEvents
File created rename rename add
File modified change change change
File deleted rename rename unlink
File renamed rename rename unlink + add
Dir created rename rename addDir
Dir deleted rename rename unlinkDir

Benchmark Goals

Scenario fs.watch() + chokidar processing Rush-FS watch()
100-file directory change JS processes ~100 raw events Rust coalesces to ~10 events
10k files continuous monitoring CPU ~2-3% < 0.5%
Editor save (atomic write) Triggers 2-3 events debounce merges to 1
macOS large directory scan Often loses filenames FSEvents native + path resolution

Full RFC document available at: rush-fs-watch-rfc.md (to be added)

Happy to drive implementation if this gets green-lit 🦀

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions