@nds-stack/bun-watcher
v0.1.1
Published
File system watcher for Bun — recursive watching, debounce, glob filtering, zero dependencies
Maintainers
Readme
@nds-stack/bun-watcher
File system watcher for Bun — recursive watching, debounce, glob filtering, zero dependencies.
Why bun-watcher
Bun doesn't have a built-in recursive file watcher with debounce and glob filtering. The alternative — chokidar — is the gold standard for Node.js file watching but includes polyfills for Node.js APIs that Bun doesn't need.
bun-watcher is a lightweight, Bun-native alternative:
- Zero npm dependencies — uses Bun's native
fs.watch()with recursive support - Chokidar-compatible API — drop-in replacement for most use cases
- Native OS events — uses kqueue/inotify/ReadDirectoryChangesW, no polling
- Debounced — batches rapid events within a configurable window
- Binary detection — skips binary files by default for cleaner output
import { watch } from "@nds-stack/bun-watcher";
const w = watch("./src", { ignored: ["node_modules"] });
w.on("all", (event, path) => console.log(event, path));
w.on("ready", () => console.log("Ready!"));Installation
bun add @nds-stack/bun-watcherAPI
watch(paths, options?)
Creates a watcher and starts scanning immediately. Returns a Watcher instance.
| Param | Type | Default | Description |
|-------|------|---------|-------------|
| paths | string \| string[] | — | File or directory path(s) to watch |
| options.ignored | (string \| RegExp)[] | [] | Ignore patterns. Strings match if path includes them; RegExp tests the path. |
| options.ignoreInitial | boolean | false | If true, skip add events for files that exist at watch start. |
| options.recursive | boolean | true | Watch subdirectories recursively. |
| options.delay | number | 100 | Debounce delay in ms for coalescing rapid change events. Minimum 0 (no debounce). |
| options.maxDepth | number | Infinity | Maximum directory depth to scan. |
| options.ignoreBinary | boolean | true | Skip files with binary extensions (images, videos, archives, binaries, etc.). |
| options.followSymlinks | boolean | false | Follow symbolic links when scanning. |
| options.alwaysStat | boolean | false | Include { size, mtime } in add and change events. |
| options.awaitWriteFinish | boolean \| object | false | Wait for file writes to settle before emitting change. Pass true or { stabilityThreshold: 500, pollInterval: 100 }. |
Events
| Event | Signature | Description |
|-------|-----------|-------------|
| all | (event, path, stat?) | Catch-all. stat provided when alwaysStat: true. |
| add | (path, stat?) | New file detected. stat provided when alwaysStat: true. |
| change | (path, stat?) | File modified (debounced). stat provided when alwaysStat: true. |
| unlink | (path) | File deleted |
| ready | () | Initial scan complete |
| error | (err: Error) | Watcher error |
Methods
| Method | Description |
|--------|-------------|
| on(event, handler) | Register event handler. Returns this for chaining. |
| off(event, handler) | Remove event handler. |
| close() | Stop watching, clear timers, remove all listeners. |
How It Works
bun-watcher uses native OS file system events via fs.watch() with recursive: true:
- Initial scan:
Bun.Glob("**/*")discovers existing files for baseline tracking - File tracking: All known files are stored in a
Map<string, FileStat>for tracking file metadata - Native events:
fs.watch(backed by kqueue/inotify/ReadDirectoryChangesW) reports file additions, deletions, and modifications in real-time - Add/unlink detection: When a
renameevent fires, the file's existence is checked to distinguish creation from deletion - Debounce:
changeevents are delayed bydelayms. If another change happens within the window, the timer resets. - Binary filtering: File extension is checked against a set of ~50 known binary extensions before processing.
This approach is fast, CPU-efficient, and cross-platform — zero overhead when no files are changing.
Error Handling
bun-watcher handles errors gracefully:
- Watch errors: If a directory doesn't exist or is inaccessible, an
errorevent is emitted (but the watcher continues for other paths) - Handler errors: Errors in event handlers are silently caught — they won't crash the watcher
- File stat errors: If a file disappears during a scan cycle, it's silently skipped
- close() safety: Calling
close()multiple times is safe — it clears all state
const w = watch("/some/path");
w.on("error", (err) => {
console.error("Watcher error (non-fatal):", err.message);
});Limitations
- Initial scan overhead:
Bun.Glob("**/*")runs once at start to discover existing files. For directories with >10K files, useignoreInitial: trueto skip initial events. - Symlinks: Symlinks are not followed. Only regular files are tracked.
- Atomic writes: Writes that replace a file atomically (write to temp then rename) are detected as an unlink of the old file + add of the new file.
- No stat info by default: Without
alwaysStat: true, only file path events are reported. EnablealwaysStatto receive{ size, mtime }inadd/changeevents. - Cross-platform semantics: Different OS kernels report events slightly differently.
fs.watch'srenameevent may fire for both creates and deletes — the watcher uses an internalSetto distinguish betweenaddandunlink.
Multi-Instance / Cross-Boundary
bun-watcher is per-process. In multi-instance deployments, each instance maintains its own file set. This is fine for most use cases since file watching is inherently local to a process.
For distributed file watching (e.g., watching the same NFS mount from multiple servers), combine with a message queue or shared state:
import { watch } from "@nds-stack/bun-watcher";
// import { redis } from "./redis";
const w = watch("/shared/data", { ignoreInitial: true });
w.on("change", (path) => {
// Broadcast changes to other instances
// redis.publish("file:changes", path);
});Customization Guide
Custom ignore logic
Extend ignore behavior by combining patterns:
const w = watch(".", {
ignored: [
/\.(log|tmp)$/, // RegExp: ignore log and tmp files
"node_modules", // String: ignore paths containing "node_modules"
".git", // String: ignore .git directory
],
});Only watch specific extensions
const w = watch(".", {
ignored: [/\.(?!ts$|js$)/], // Only .ts and .js files
});Adjust poll frequency for large projects
// For large directories, less frequent polling reduces CPU
const w = watch(".", {
delay: 1000, // Poll every 1 second instead of 100ms
maxDepth: 3, // Only scan 3 levels deep
});Mix with other event sources
import { watch } from "@nds-stack/bun-watcher";
class BuildWatcher {
private w = watch("./src", { ignored: ["node_modules"] });
constructor() {
this.w.on("change", (path) => this.onChange(path));
this.w.on("error", (err) => console.error(err));
}
private onChange(path: string): void {
console.log(`Rebuilding due to: ${path}`);
// Trigger build...
}
close(): void {
this.w.close();
}
}Comparison Table
| Feature | chokidar (npm) | @rabbx/watcher (npm) | bun-watcher | |---------|----------------|----------------------|-------------| | Native OS watcher | ✅ kqueue, inotify, FSEvents, polling | 🔄 auto-fallback | ✅ kqueue, inotify, ReadDirectoryChangesW | | Cross-platform | ✅ Node.js, Bun, Deno | ✅ Node.js, Bun, Deno | ✅ Bun | | Debounce | ✅ | ✅ | ✅ | | Glob filtering | ✅ (anymatch) | ✅ (include/exclude) | ✅ (string & RegExp) | | ignoreInitial | ✅ | ✅ | ✅ | | Binary detection | ❌ | ✅ | ✅ | | Max depth | ❌ | ✅ | ✅ | | Zero dependencies | ❌ (~15 deps) | ✅ | ✅ | | Bun-native | ❌ (polyfills) | ✅ | ✅ | | TypeScript types | ✅ | ✅ | ✅ | | Bundle size | ~200KB + deps | ~3KB | ~3KB |
Benchmarks
Methodology
- Initial scan of 50 files on disk
- Warmup: 1 cycle before measurement
- Hardware: Bun v1.3.14 (Windows 11)
Results (ops/s — higher is better)
| Operation | bun-watcher | chokidar | @rabbx/watcher | Native Bun.Glob |
|-----------|:-------------:|:----------:|:----------------:|:-----------------:|
| Initial scan (50 files) | 8K | 492 | 31K^ | 135K 🥇 |
^
@rabbx/watcherfires "ready" before native watcher is set up (via microtask before asyncimport("node:fs")resolves). bun-watcher fires "ready" only after both initial scan AND native watcher are fully ready.
Run bun run bench on your hardware to reproduce.
Real-World Example
import { watch } from "@nds-stack/bun-watcher";
interface BuildEvent {
type: "add" | "change" | "unlink";
path: string;
timestamp: number;
}
class DevServer {
private watcher = watch("./src", {
ignored: ["node_modules", ".git", /\.(test|spec)\.(ts|js)$/],
ignoreInitial: true,
delay: 150,
});
private buildQueue: BuildEvent[] = [];
private buildTimer: Timer | null = null;
constructor() {
this.watcher.on("all", (event, path) => {
this.buildQueue.push({ type: event, path, timestamp: Date.now() });
this.scheduleBuild();
});
this.watcher.on("ready", () => console.log("Watching for changes..."));
this.watcher.on("error", (err) => console.error("Watcher error:", err));
}
private scheduleBuild(): void {
if (this.buildTimer) clearTimeout(this.buildTimer);
this.buildTimer = setTimeout(() => this.runBuild(), 500);
}
private async runBuild(): Promise<void> {
const events = [...this.buildQueue];
this.buildQueue = [];
console.log(`Rebuilding (${events.length} change(s))...`);
// Run build...
}
close(): void {
this.watcher.close();
}
}License
MIT
