Skip to content

Commit b32d2fd

Browse files
mcollinaclaude
andcommitted
vfs: add watch and watchFile support
Add fs.watch(), fs.watchFile(), fs.unwatchFile(), and fs.promises.watch() support for VFS-mounted paths using a polling-based implementation. Key features: - VFSWatcher: polling-based watcher compatible with fs.watch() interface - VFSStatWatcher: stat-based watcher compatible with fs.watchFile() - VFSWatchAsyncIterable: async iterator for fs.promises.watch() - Recursive directory watching via tracked files map - AbortSignal support for cancellation - Overlay mode: watches VFS if file exists, falls through to real fs The implementation follows "Approach A (VFS Priority)" where VFS files take precedence over real filesystem files under mount points. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 9560062 commit b32d2fd

File tree

7 files changed

+1244
-3
lines changed

7 files changed

+1244
-3
lines changed

lib/internal/vfs/file_handle.js

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
'use strict';
22

33
const {
4+
DateNow,
45
MathMin,
56
Symbol,
67
} = primordials;
@@ -385,9 +386,10 @@ class MemoryFileHandle extends VirtualFileHandle {
385386
// Write the data
386387
data.copy(this.#content, writePos);
387388

388-
// Update the entry's content
389+
// Update the entry's content and mtime
389390
if (this.#entry) {
390391
this.#entry.content = this.#content;
392+
this.#entry.mtime = DateNow();
391393
}
392394

393395
// Update position if not using explicit position
@@ -466,9 +468,10 @@ class MemoryFileHandle extends VirtualFileHandle {
466468
this.#content = Buffer.from(buffer);
467469
}
468470

469-
// Update the entry's content
471+
// Update the entry's content and mtime
470472
if (this.#entry) {
471473
this.#entry.content = this.#content;
474+
this.#entry.mtime = DateNow();
472475
}
473476

474477
this.position = this.#content.length;
@@ -521,9 +524,10 @@ class MemoryFileHandle extends VirtualFileHandle {
521524
this.#content = newContent;
522525
}
523526

524-
// Update the entry's content
527+
// Update the entry's content and mtime
525528
if (this.#entry) {
526529
this.#entry.content = this.#content;
530+
this.#entry.mtime = DateNow();
527531
}
528532
}
529533

lib/internal/vfs/file_system.js

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -885,6 +885,58 @@ class VirtualFileSystem {
885885
return createVirtualReadStream(this, filePath, options);
886886
}
887887

888+
// ==================== Watch Operations ====================
889+
890+
/**
891+
* Watches a file or directory for changes.
892+
* @param {string} filePath The path to watch
893+
* @param {object|Function} [options] Watch options or listener
894+
* @param {Function} [listener] Change listener
895+
* @returns {EventEmitter} A watcher that emits 'change' events
896+
*/
897+
watch(filePath, options, listener) {
898+
if (typeof options === 'function') {
899+
listener = options;
900+
options = {};
901+
}
902+
903+
const providerPath = this._toProviderPath(filePath);
904+
const watcher = this[kProvider].watch(providerPath, options);
905+
906+
if (listener) {
907+
watcher.on('change', listener);
908+
}
909+
910+
return watcher;
911+
}
912+
913+
/**
914+
* Watches a file for changes using stat polling.
915+
* @param {string} filePath The path to watch
916+
* @param {object|Function} [options] Watch options or listener
917+
* @param {Function} [listener] Change listener
918+
* @returns {EventEmitter} A stat watcher that emits 'change' events
919+
*/
920+
watchFile(filePath, options, listener) {
921+
if (typeof options === 'function') {
922+
listener = options;
923+
options = {};
924+
}
925+
926+
const providerPath = this._toProviderPath(filePath);
927+
return this[kProvider].watchFile(providerPath, options, listener);
928+
}
929+
930+
/**
931+
* Stops watching a file for changes.
932+
* @param {string} filePath The path to stop watching
933+
* @param {Function} [listener] Optional listener to remove
934+
*/
935+
unwatchFile(filePath, listener) {
936+
const providerPath = this._toProviderPath(filePath);
937+
this[kProvider].unwatchFile(providerPath, listener);
938+
}
939+
888940
// ==================== Promise API ====================
889941

890942
/**
@@ -989,6 +1041,11 @@ function createPromisesAPI(vfs) {
9891041
const providerPath = vfs._toProviderPath(filePath);
9901042
return provider.access(providerPath, mode);
9911043
},
1044+
1045+
watch(filePath, options) {
1046+
const providerPath = vfs._toProviderPath(filePath);
1047+
return provider.watchAsync(providerPath, options);
1048+
},
9921049
});
9931050
}
9941051

lib/internal/vfs/module_hooks.js

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,14 @@ let originalExistsSync = null;
3434
let originalPromisesReaddir = null;
3535
// Original fs/promises.lstat function
3636
let originalPromisesLstat = null;
37+
// Original fs.watch function
38+
let originalWatch = null;
39+
// Original fs.watchFile function
40+
let originalWatchFile = null;
41+
// Original fs.unwatchFile function
42+
let originalUnwatchFile = null;
43+
// Original fs/promises.watch function
44+
let originalPromisesWatch = null;
3745
// Track if hooks are installed
3846
let hooksInstalled = false;
3947

@@ -282,6 +290,33 @@ async function findVFSForLstatAsync(filename) {
282290
return null;
283291
}
284292

293+
/**
294+
* Checks all active VFS instances for watch operations.
295+
* For Approach A (VFS Priority): watch VFS if file exists, otherwise fall through.
296+
* @param {string} filename The path to watch
297+
* @returns {{ vfs: VirtualFileSystem }|null}
298+
*/
299+
function findVFSForWatch(filename) {
300+
const normalized = normalizePath(filename);
301+
for (let i = 0; i < activeVFSList.length; i++) {
302+
const vfs = activeVFSList[i];
303+
if (vfs.shouldHandle(normalized)) {
304+
// In overlay mode, only handle if file exists in VFS
305+
// In mount mode (default), always handle paths under mount point
306+
if (vfs.overlay) {
307+
if (vfs.existsSync(normalized)) {
308+
return { vfs };
309+
}
310+
// File doesn't exist in VFS, fall through to real fs.watch
311+
continue;
312+
}
313+
// Mount mode: always handle
314+
return { vfs };
315+
}
316+
}
317+
return null;
318+
}
319+
285320
/**
286321
* Determine module format from file extension.
287322
* @param {string} url The file URL
@@ -565,6 +600,75 @@ function installHooks() {
565600
return originalPromisesLstat.call(fsPromises, path, options);
566601
};
567602

603+
// Override fs.watch
604+
originalWatch = fs.watch;
605+
fs.watch = function watch(filename, options, listener) {
606+
// Handle optional options argument
607+
if (typeof options === 'function') {
608+
listener = options;
609+
options = {};
610+
} else if (options == null) {
611+
options = {};
612+
}
613+
614+
if (typeof filename === 'string' || filename instanceof URL) {
615+
const pathStr = typeof filename === 'string' ? filename : filename.pathname;
616+
const vfsResult = findVFSForWatch(pathStr);
617+
if (vfsResult !== null) {
618+
return vfsResult.vfs.watch(pathStr, options, listener);
619+
}
620+
}
621+
return originalWatch.call(fs, filename, options, listener);
622+
};
623+
624+
// Override fs.watchFile
625+
originalWatchFile = fs.watchFile;
626+
fs.watchFile = function watchFile(filename, options, listener) {
627+
// Handle optional options argument
628+
if (typeof options === 'function') {
629+
listener = options;
630+
options = {};
631+
} else if (options == null) {
632+
options = {};
633+
}
634+
635+
if (typeof filename === 'string' || filename instanceof URL) {
636+
const pathStr = typeof filename === 'string' ? filename : filename.pathname;
637+
const vfsResult = findVFSForWatch(pathStr);
638+
if (vfsResult !== null) {
639+
return vfsResult.vfs.watchFile(pathStr, options, listener);
640+
}
641+
}
642+
return originalWatchFile.call(fs, filename, options, listener);
643+
};
644+
645+
// Override fs.unwatchFile
646+
originalUnwatchFile = fs.unwatchFile;
647+
fs.unwatchFile = function unwatchFile(filename, listener) {
648+
if (typeof filename === 'string' || filename instanceof URL) {
649+
const pathStr = typeof filename === 'string' ? filename : filename.pathname;
650+
const vfsResult = findVFSForWatch(pathStr);
651+
if (vfsResult !== null) {
652+
vfsResult.vfs.unwatchFile(pathStr, listener);
653+
return;
654+
}
655+
}
656+
return originalUnwatchFile.call(fs, filename, listener);
657+
};
658+
659+
// Override fs/promises.watch
660+
originalPromisesWatch = fsPromises.watch;
661+
fsPromises.watch = function watch(filename, options) {
662+
if (typeof filename === 'string' || filename instanceof URL) {
663+
const pathStr = typeof filename === 'string' ? filename : filename.pathname;
664+
const vfsResult = findVFSForWatch(pathStr);
665+
if (vfsResult !== null) {
666+
return vfsResult.vfs.promises.watch(pathStr, options);
667+
}
668+
}
669+
return originalPromisesWatch.call(fsPromises, filename, options);
670+
};
671+
568672
// Register ESM hooks using Module.registerHooks
569673
Module.registerHooks({
570674
resolve: vfsResolveHook,

lib/internal/vfs/provider.js

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,14 @@ class VirtualProvider {
3434
return false;
3535
}
3636

37+
/**
38+
* Returns true if this provider supports file watching.
39+
* @returns {boolean}
40+
*/
41+
get supportsWatch() {
42+
return false;
43+
}
44+
3745
// === ESSENTIAL PRIMITIVES (must be implemented by subclasses) ===
3846

3947
/**
@@ -492,6 +500,57 @@ class VirtualProvider {
492500
}
493501
throw new ERR_METHOD_NOT_IMPLEMENTED('symlinkSync');
494502
}
503+
504+
// === WATCH OPERATIONS (optional, polling-based) ===
505+
506+
/**
507+
* Watches a file or directory for changes.
508+
* Returns an EventEmitter-like object that emits 'change' and 'close' events.
509+
* @param {string} path The path to watch
510+
* @param {object} [options] Watch options
511+
* @param {number} [options.interval] Polling interval in ms (default: 100)
512+
* @param {boolean} [options.recursive] Watch subdirectories (default: false)
513+
* @returns {EventEmitter} A watcher that emits 'change' events
514+
*/
515+
watch(path, options) {
516+
throw new ERR_METHOD_NOT_IMPLEMENTED('watch');
517+
}
518+
519+
/**
520+
* Watches a file or directory for changes (async iterable version).
521+
* Used by fs.promises.watch().
522+
* @param {string} path The path to watch
523+
* @param {object} [options] Watch options
524+
* @param {number} [options.interval] Polling interval in ms (default: 100)
525+
* @param {boolean} [options.recursive] Watch subdirectories (default: false)
526+
* @param {AbortSignal} [options.signal] AbortSignal for cancellation
527+
* @returns {AsyncIterable} An async iterable that yields change events
528+
*/
529+
watchAsync(path, options) {
530+
throw new ERR_METHOD_NOT_IMPLEMENTED('watchAsync');
531+
}
532+
533+
/**
534+
* Watches a file for changes using stat polling.
535+
* Returns a StatWatcher-like object that emits 'change' events with stats.
536+
* @param {string} path The path to watch
537+
* @param {object} [options] Watch options
538+
* @param {number} [options.interval] Polling interval in ms (default: 5007)
539+
* @param {boolean} [options.persistent] Whether the watcher should prevent exit
540+
* @returns {EventEmitter} A stat watcher that emits 'change' events
541+
*/
542+
watchFile(path, options) {
543+
throw new ERR_METHOD_NOT_IMPLEMENTED('watchFile');
544+
}
545+
546+
/**
547+
* Stops watching a file for changes.
548+
* @param {string} path The path to stop watching
549+
* @param {Function} [listener] Optional listener to remove
550+
*/
551+
unwatchFile(path, listener) {
552+
throw new ERR_METHOD_NOT_IMPLEMENTED('unwatchFile');
553+
}
495554
}
496555

497556
module.exports = {

0 commit comments

Comments
 (0)