Ecosystem ↗

Shared file watching

Set up zlook with explicit lifecycle, typed public APIs and deployment boundaries.

↗ View Markdown

Installation and integration path#

sh
npm install zlook@0.0.4

This package has its own public API. Installing it does not create an automatic runtime service key in zerux.config.js. Use the framework integration described below or initialize it in the tool/application that owns its lifetime.

Worked TypeScript setup#

ts
import { watchDirectory } from 'zlook';
const watcher = watchDirectory('./src', event => console.log(event), {
  debounceMs: 100, pollIntervalMs: 2000,
  ignored: file => file === '.cache' || file.startsWith('.cache/'),
  onError: error => console.error(error.message),
});
await watcher.ready;
await watcher.flush();
watcher.close();
await watcher.closed;

JavaScript can use the same runtime APIs without type-only statements. Enable JSDoc checking if editor validation is desired; a transpilation step alone does not typecheck the input.

Usage and configuration#

Create one subscriber per development task and await its baseline before relying on changes. Subscribers to the same canonical directory share underlying work while retaining separate ignore rules and callbacks. Sharing is process-local, not a daemon or cross-process lock. Native recursive watching falls back to polling on unsupported hosts.

Ignore generated output to avoid rebuild loops. Watch only trusted source directories and keep callback work debounced; a callback can still be expensive even when the watcher is shared. ready establishes the baseline and flush forces reconciliation. startWatcher remains a simpler API returning a stop callback.

Public types#

ts
import { watchDirectory } from 'zlook';
type Inputs = Parameters<typeof watchDirectory>;
type Result = Awaited<ReturnType<typeof watchDirectory>>;

Use installed public declarations and validate untrusted runtime values separately. Avoid deep source imports; the manifest reference below records the actual exported entry points for this version. Generic helpers may preserve a more useful concrete type at the call site than a broad ReturnType alias.

Failures, ownership and production use#

Close every handle on reload/shutdown. Callback errors and filesystem errors should be surfaced through the configured error handling. Polling observes metadata, so same-size writes with preserved modification time can be missed. A scan is O(files); validate the chosen polling interval against the project size. Verify add/change/delete, ignored paths, symlinks and nested directory changes on the actual operating system.

Verify the integration#

Run the real owning application/tool with a normal input and a failure input. Inspect exit status or typed results, then verify cleanup/restart and the installed production entry point. A successful docs build does not exercise child processes, browser APIs, sockets or filesystem permissions on every supported operating system.

Option reference#

WatchOptions#

Option Accepted TypeScript value Required Meaning
debounceMs number No Integer 0–600000ms trailing debounce; defaults to 60.
pollIntervalMs number No Fallback scan interval; also detects changes missed by native notifications.
ignored (relativePath: string) => boolean No Ignore paths relative to the watched root.
onError (error: Error) => void No Isolated handler for scan/native/callback failures; causes remain private.
maxEntries number No Integer 1–1000000 examined names per scan; defaults to 100000.
maxPendingEvents number No Integer 1–100000 retained changes per reconciliation; defaults to 4096.
pollingOnly boolean No Suppresses native allocation; periodic reconciliation remains active.
signal AbortSignal No Closes this subscription independently, including during startup.

Bounded reconciliation and subscriber isolation#

Sharing identity is the canonical existing root plus debounceMs, pollIntervalMs, maxEntries, maxPendingEvents and pollingOnly. Missing roots use a resolved lexical path until a new subscription canonicalizes them. Distinct settings do not change another subscriber's timing. Options are snapshotted; ignore/error/signal handlers stay local. Built-in .git/node_modules/.svn segments are pruned. Custom synchronous pure ignored predicates match normalized relative paths and ancestor prefixes, filter callbacks and flush results, and do not prune the shared scan. Configure dist/build explicitly to prevent rebuild loops. New subscribers cannot hide another's files.

Only new/update/delete file events exist: rename is delete+new, directories produce no standalone event, and bursts reconcile final metadata. Baseline scan is silent. A create/delete between scans or same-size write preserving mtime may be missed. Events are path-sorted, not chronological. This is not full chokidar parity.

Defaults are debounce 60ms, polling 2000ms, maxEntries 100000, maxPendingEvents 4096. Entry bound is 1–1000000; event bound is 1–100000. Streaming traversal counts examined entry names, including ignored names but not their pruned contents. There are at most four process-local scans, 128 admission waiters, and one active/one trailing refresh per identity. Additional refresh requests share work instead of growing a queue. Exported scan(root, ignored, {maxEntries, signal}) uses the same admission limit. It has no default ignores and rejects filesystem failures; watcher failures report through onError and flush resolves empty while preserving the previous baseline.

Missing roots, permission errors and limits never become synthetic deletes. Missing children may race writes and are tolerated. Failed startup still resolves ready after reporting; a later successful poll establishes a silent baseline. ScanError exposes a redacted message/code; raw cause is internal and can contain private paths. Descendant symlinks are not traversed; trusted root aliases are canonicalized. These checks are not a sandbox against concurrent malicious tree replacement.

ts
import { watchDirectory } from 'zlook';
const watcher = watchDirectory('./src', event => console.log(event.type, event.file), {
  ignored: file => ['dist', 'build', '.cache'].includes(file),
  maxEntries: 100000, maxPendingEvents: 4096,
  onError: error => console.error(error.message)
});
try {
  await watcher.ready;
  await watcher.flush();
  console.log(watcher.diagnostics());
} finally { watcher.close(); await watcher.closed; }

A subscription accepts signal and closes independently on abort. close is idempotent before/during startup. The last subscriber aborts traversal, removes registry membership, cancels timers and releases its native handle once. Await closed to observe scan/native cleanup. An OS read already in flight cannot be forcibly interrupted. Async consumer callbacks are not awaited; their owner must serialize/cancel that separate work.

Native events reset a trailing debounce; periodic polling can request reconciliation sooner. pollingOnly=true forces fallback. A failed native backend is reported and not recreated repeatedly; polling continues. See Node watch caveats and directory iteration. No Windows/macOS/network filesystem parity is claimed by local Node tests.

watcherDiagnostics and handle.diagnostics contain counts and lastErrorCode, not paths or causes. activeWatcherCount counts shared identities, not subscribers or individual handles. scans includes startup, scanErrors counts failed scans, emitted counts retained shared events before filters, coalesced counts merged notification/refresh requests, and dropped counts overflow only. On overflow the sorted first maxPendingEvents changes are delivered and PENDING_EVENT_LIMIT is reported; the full baseline advances, so omitted changes are not replayed. Perform full application reconciliation after that error. Ignore-filtered changes are not drops, and coalescing cannot measure write counts. Tests exercise permission denial, fallback failure, overflow, burst/rename, independent ignores, startup cancellation and offline installed public declarations. No app is started.

Public entry points

Manifest reference for zlook@0.0.4. Each target describes a published entry, not automatic framework registration.

ImportTarget
zlook
{
  "types": "./dist/index.d.ts",
  "import": "./dist/index.js"
}
Runtime requirements and dependencies
{
  "engines": {},
  "dependencies": {},
  "peerDependencies": {},
  "bin": {}
}
Full package README

zlook#

Filesystem watcher for ZeruxJS, with shared in-process subscriptions, native notifications and a polling fallback. File metadata changes are reconciled after debouncing, and watchers are disposed when the last subscriber unsubscribes.

ts
import { startWatcher } from 'zlook';
const stop = startWatcher('src', (event) => console.log(event.file, event.type), {
  debounceMs: 75,
  pollIntervalMs: 1500,
  ignored: (file) => file.endsWith('.log'),
  onError: console.error
});
// During shutdown: stop();

Events: new, update, delete; paths use / relative to the watched root. Default exclusions: .git, node_modules, .svn. Symbolic links are not traversed. scan and diff are exported for custom watcher integrations.

Limitations: sharing is process-local, not cross-process; polling uses file metadata, so a same-size write preserving modification time may be missed; a complete scan costs O(files). Filesystem read errors are surfaced through onError. Watch paths should be trusted; callbacks execute in the caller's process.

Shared watcher lifecycle#

Use watchDirectory() when you need to await the baseline scan or force reconciliation:

ts
import { watchDirectory } from 'zlook';
const watcher = watchDirectory('./src', (event) => console.log(event), {
  ignored: file => file === '.cache' || file.startsWith('.cache/'),
  pollIntervalMs: 2000,
  onError: console.error
});
await watcher.ready;
await watcher.flush();
watcher.close();

Subscribers to the same canonical directory and identical scheduling/resource settings share underlying watchers without sharing their ignore rules. startWatcher() is retained for existing callers and returns a disposal callback. The scanner does not traverse symlinks.

Watcher validation#

watchDirectory(root, callback, { debounceMs, pollIntervalMs, onError }) validates callback/root types and integer millisecond settings before creating any shared native watcher. Polling must be at least 100ms when configured; intervals are bounded to ten minutes. Subscriber callbacks remain isolated, errors are reported through onError, and handles must be closed during hot reload/unmount. Filesystem symlinks are never traversed by the recursive scan. The fallback polling path is retained for hosts where recursive fs.watch is unsupported. Test with node --experimental-strip-types --test test/*.test.ts after building.

Reconciliation and sharing contract#

This is a bounded metadata reconciler, not complete chokidar parity. No independent rename, directory event, inode tracking, atomic-write or await-write-finish API is provided. The initial successful scan establishes a silent baseline. A rename normally produces delete(old path) plus new(new path); only the final observed state matters. Several writes between snapshots produce at most one update for a path. A create/delete entirely between scans can be invisible. Events are sorted by relative path, not write order, and contain file paths only. Modification time and size comparisons can miss a same-size write with preserved mtime. Directory creation itself has no event; new files underneath it are included on the next full scan.

Native notifications reset a trailing debounce timer (default 60ms). Polling also requests full reconciliation (default 2000ms) and remains enabled even with native support. Poll requests can advance an outstanding debounce; it is not a guarantee that every write waits the entire debounce period. pollingOnly: true explicitly disables native handles. On native construction/error failure, onError receives a redacted ScanError and polling continues; native setup is attempted at most once per shared watcher, never repeatedly recreated after backend failure. See Node fs.watch caveats for platform/network filesystem limitations. No platform or chokidar parity is implied.

Identity consists of fs.realpath(root) plus debounceMs, pollIntervalMs, maxEntries, maxPendingEvents and pollingOnly. Missing roots temporarily use the resolved lexical path; create a new subscription to canonicalize an alias after it becomes available. Different settings get separate underlying watchers. This changes older behavior where a new subscriber could silently shorten everyone's timing. Options are snapshotted, and ignored/onError/signal remain subscriber-specific. Close old handles on reload; subscribing again without disposing is intentionally another subscriber. Process-local sharing is not cross-process watcher deduplication.

Built-in .git/node_modules/.svn directory segments are pruned from all scans. Subscriber ignored predicates are synchronous, pure functions receiving normalized relative paths, including ancestor prefixes. Ignoring dist also hides dist/file.js for that subscriber only. Custom ignores filter callback and flush results; they do not prune the shared baseline or reduce shared scan cost. New subscribers cannot hide files from existing ones. Returning a Promise/non-boolean or throwing reports an error only to that subscriber and suppresses its batch; other subscribers continue. Generated dist/build/.cache paths are not excluded globally: configure them explicitly to avoid rebuild loops. This is predicate matching, not glob syntax. Callback events are frozen copies; subscriber mutation cannot corrupt another's data. Async callback rejection is reported, but callback promises are not awaited or serialized. Long-running consumers must serialize/cancel their own work; close does not wait for arbitrary user callback promises.

Bounds, failures and ownership#

Setting/resource Default Bound/behavior
maxEntries 100000 1–1000000 directory entries examined per scan, including ignored names but not their pruned descendants
maxPendingEvents 4096 1–100000 retained events per reconciliation
debounceMs 60 0–600000 integer milliseconds
pollIntervalMs 2000 100–600000 integer milliseconds
Scan concurrency 4 Process-local limit shared by exported scan and watcher scans
Scan admission queue 128 Excess calls fail with SCAN_QUEUE_LIMIT; queued abort removes admission
Reconciliation requests One active, one trailing per shared identity Additional requests share a trailing promise, not an unbounded promise chain

Traversal uses streaming opendir iteration (directory handles close on completion, throw and cancellation), lstat and bounded pending directories. Descendant symlinks are not followed, including cycles. watchDirectory canonicalizes trusted root aliases; standalone scan rejects a symlink root. Direct scan takes an optional third {maxEntries, signal} argument and rejects failures; it has no global ignore defaults. Only the watcher supplies built-in exclusions. Paths/trees must be trusted: these checks are not a race-free sandbox against concurrent malicious symlink replacement. See Node opendir.

Root canonicalization failures other than ENOENT throw a redacted ScanError before allocating a subscription. A child disappearing with ENOENT/ENOTDIR is a tolerated scan race. Missing roots, permission failures, limits and other filesystem failures report through onError on each failed attempt. A failed scan retains the last complete baseline and emits no synthetic mass deletes. If startup fails, ready still resolves after reporting its error for compatibility; the first later successful scan establishes a silent baseline. Polling can recover a missing root. No empty snapshot is claimed as a successful scan. ScanError.message/code omit paths; its cause can contain private paths and must remain internal. Callbacks receive relative paths, which may still be sensitive. Callback/ignore errors are application errors and may contain private messages; do not blindly publish errors or causes.

On event overflow, the sorted first maxPendingEvents file changes are delivered, remaining changes are counted as dropped and PENDING_EVENT_LIMIT is reported. The complete snapshot still advances: dropped changes are not replayed by the next flush. Perform application-level full reconciliation after overflow or increase the explicit bound. Coalescing scan requests never increments dropped; it represents shared work, not a known lost file event. Native notification coalescing cannot measure how many filesystem writes occurred. A flush while scanning waits for the single trailing reconciliation; concurrent flushes can share that result. A closed subscriber's flush returns empty. An already-running flush may finish its shared scan, but closing prevents further callback delivery.

ts
import { watchDirectory, activeWatcherCount } from 'zlook';
const controller = new AbortController();
const watcher = watchDirectory('./src', event => console.log(event.type, event.file), {
  signal: controller.signal,
  ignored: file => ['dist', 'build', '.cache'].includes(file),
  maxEntries: 100000, maxPendingEvents: 4096,
  onError: error => console.error(error.message)
});
try {
  await watcher.ready;
  await watcher.flush();
  console.log(watcher.diagnostics()); // Counts and error code; no paths or causes.
} finally {
  watcher.close(); // Or controller.abort(); only this subscription is closed.
  await watcher.closed;
}
console.log(activeWatcherCount());

close is idempotent and can run before ready. The last subscriber immediately removes shared registry membership, aborts queued/in-flight traversal at cooperative checkpoints, cancels timers and closes its native handle exactly once. Await closed for scan cleanup and the native close event; OS filesystem operations already in progress cannot be forcibly interrupted. Closing one subscriber does not cancel another's shared scan. startWatcher retains its disposal-function API; use watchDirectory for awaitable cleanup. Unref'ed native scheduling/poll timers do not keep a process alive by themselves; a live native watcher can do so.

Diagnostics and verification#

handle.diagnostics() returns a frozen count snapshot even after closing. watcherDiagnostics() returns active shared identities' snapshots without root/file paths. activeWatcherCount counts shared registry entries, not subscriptions or scan slots; await closed when verifying resource cleanup. Counters are per shared lifetime: scans includes the baseline attempt, scanErrors counts failed scans, emitted counts retained shared file events before subscriber filters, coalesced counts merged notification/refresh requests, and dropped counts event overflow only. nativeHandles, pollingHandles, debounceHandles, scanning, pendingScans, subscriptions, closed and historical lastErrorCode describe current resources/last failure. scanning also includes a scan awaiting admission; it is not a count of active OS reads. Ignore-filtered changes are not dropped. A closed subscription sharing an active watcher can still observe its aggregate diagnostics; subscription closed is its own lifecycle promise.

Focused Node checks cover burst writes, rename/delete/nested changes, build/dependency ignores, missing roots, unprivileged permission denial, symlink containment, queue limits, native-backend failure, startup abort, listener/handle cleanup and an offline npm-installed declaration consumer using shared TypeScript tooling. No live provider or browser is needed. Windows/macOS/network filesystems and real kernel-event-loss behavior need platform-specific verification; mocked native failure proves only the polling fallback path. Full scan costs O(entries) and no performance gain is claimed.

Reading and maintaining the watcher#

src/scan.ts owns process-local scan admission and complete candidate snapshots. src/watcher.ts owns shared root/settings identities, timers and native handles; subscriber ignore policies are applied after scanning so they cannot hide files from another subscriber. Failed scans keep the last complete baseline. Metadata comparison uses size and modification time, not a content hash.

Use watchDirectory when teardown must be awaited: call close() and then await closed. This drains owned startup/scans/native handles, but does not await promises returned by consumer callbacks; the consumer must cancel and drain that work. The legacy startWatcher returns a stop function only, and an omitted callback remains an inert compatibility call. Retain that disposer rather than inventing a handle API.

Implementation sources

Implementation files for the APIs described on this page.

ZeruxJS · Open source · MIT OR Apache-2.0

Make yourself at home.

Every shortcut has a visible control too. Reading and navigation work without JavaScript.

Ctrl / ⌘ K
Search all documentation
/
Search, outside editable fields
?
This help, outside editable fields
↑ / ↓
Move through search results
Esc
Close a dialog or version menu
Progress, privacy, offline reading and controls →

Reset this learning record?

This removes course answers, exercise confirmations, bookmarks and recent pages for this docs version in this browser. Export a copy first if you want to keep it.

Search documentation

Type at least two characters.