node:fs: move watchFile/unwatchFile into a lazily loaded internal module (#31424)
### What
Refactors `fs.watchFile` / `fs.unwatchFile` out of `node:fs` into a
lazily loaded internal module, as requested.
- **`src/js/internal/fs/watchfile.ts` (new):** `StatWatcher`, the
per-path stat-watcher `Map`, and the `watchFile` / `unwatchFile`
implementations. Loaded on first call, same pattern as
`internal/fs/cp-sync` / `internal/fs/glob`.
- **`src/js/node/fs.ts`:** `watchFile` / `unwatchFile` are now thin
wrappers that `require("internal/fs/watchfile")` and delegate.
- **`src/js/internal/validators.ts`:** `getValidatedPath` /
`throwIfNullBytesInFileName` moved here (needed by both the watchfile
module and the Windows `realpath`/`realpathSync` fallbacks in `node:fs`)
and hidden from stack traces like the other validators — no duplicated
helpers.
- **`src/js/internal/fs/binding.ts` (new):** owns the
`node_fs_binding.zig` `createBinding` call; `node:fs`,
`node:fs/promises`, and `internal/fs/watchfile` all require the native
binding from here instead of reaching into `fs.promises`' `$data`.
- **`src/jsc/bindings/ProcessBindingNatives.cpp`:** lists the new
internal modules in the `process.binding('natives')` table like the
other `internal/fs/*` modules.
- **Codegen for the Rust module-tag table:** adding a file to `src/js/`
shifts every generated internal module ID after it. The Rust resolver's
specifier → tag map (`INTERNAL_MODULE_TAG` in `src/jsc/lib.rs`) used to
be hand-maintained and silently went stale (a first build of this branch
had `require("fs")` resolving to the `fs.promises` module).
`bundle-modules.ts` now emits `generated_resolved_source_tag.rs`,
`lib.rs` `include!`s it, and it is declared as a codegen output +
tracked by `bun_jsc`'s build script so the crate rebuilds whenever
module IDs change. The `ResolvedSourceTag.zig` generator is removed —
the Zig sources are a non-compiled porting reference, so the generated
Zig enum had no consumers.
- **`test/js/node/watch/fs.watchFile.test.ts`:** new test covering the
shared-`StatWatcher` semantics the moved code implements (same path →
same watcher, per-listener `unwatchFile`, `stop` event when the last
listener is removed).
No behavior change intended: the implementation is moved verbatim;
`fs.watchFile` still returns the shared `StatWatcher` and
`fs.unwatchFile` still validates null bytes for unwatched paths.
### Verification (debug build, Linux x64)
- `test/js/node/watch/fs.watchFile.test.ts` — 7 pass / 0 fail (includes
the new test)
- `test/js/node/fs/promises.test.js` — 14 pass / 0 fail; FileHandle +
`createReadStream` (the other `$data` consumers) exercised manually
- `test/js/node/watch/fs.watch.test.ts`,
`test/js/node/process/process.test.js` — only failures are pre-existing
root-container/env issues (`USER` unset, permission tests as uid 0);
identical on unmodified release bun
- Node parallel suite: `test-fs-watchfile`,
`test-fs-watchfile-ref-unref`, `test-fs-null-bytes`,
`test-fs-watch-file-enoent-after-deletion`,
`test-worker-fs-stat-watcher`, `test-fs-options-immutable`,
`test-fs-promises-file-handle-readFile` — all pass
- `async-context-fs-watchFile` AsyncLocalStorage fixture passes
- Because the module IDs shifted (twice), spot-checked ~50 builtins and
required every entry of `module.builtinModules` — identical results to
release bun
- `process.binding('natives')` reports `internal/fs/watchfile` and
`internal/fs/binding`
- `cargo fmt --check` (bun_jsc), prettier, oxlint clean; `tsc -p src/js`
error count unchanged R
robobun committed
db928c247420c58cfd6ea38730c850eb2d96b0ac
Parent: c89f30d
Committed by GitHub <noreply@github.com>
on 5/27/2026, 6:46:56 PM