Skip to content

Commit 826d4ae

Browse files
committed
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.
1 parent 9560062 commit 826d4ae

File tree

7 files changed

+1243
-3
lines changed

7 files changed

+1243
-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: 102 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,73 @@ 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+
}
611+
options ??= {};
612+
613+
if (typeof filename === 'string' || filename instanceof URL) {
614+
const pathStr = typeof filename === 'string' ? filename : filename.pathname;
615+
const vfsResult = findVFSForWatch(pathStr);
616+
if (vfsResult !== null) {
617+
return vfsResult.vfs.watch(pathStr, options, listener);
618+
}
619+
}
620+
return originalWatch.call(fs, filename, options, listener);
621+
};
622+
623+
// Override fs.watchFile
624+
originalWatchFile = fs.watchFile;
625+
fs.watchFile = function watchFile(filename, options, listener) {
626+
// Handle optional options argument
627+
if (typeof options === 'function') {
628+
listener = options;
629+
options = {};
630+
}
631+
options ??= {};
632+
633+
if (typeof filename === 'string' || filename instanceof URL) {
634+
const pathStr = typeof filename === 'string' ? filename : filename.pathname;
635+
const vfsResult = findVFSForWatch(pathStr);
636+
if (vfsResult !== null) {
637+
return vfsResult.vfs.watchFile(pathStr, options, listener);
638+
}
639+
}
640+
return originalWatchFile.call(fs, filename, options, listener);
641+
};
642+
643+
// Override fs.unwatchFile
644+
originalUnwatchFile = fs.unwatchFile;
645+
fs.unwatchFile = function unwatchFile(filename, listener) {
646+
if (typeof filename === 'string' || filename instanceof URL) {
647+
const pathStr = typeof filename === 'string' ? filename : filename.pathname;
648+
const vfsResult = findVFSForWatch(pathStr);
649+
if (vfsResult !== null) {
650+
vfsResult.vfs.unwatchFile(pathStr, listener);
651+
return;
652+
}
653+
}
654+
return originalUnwatchFile.call(fs, filename, listener);
655+
};
656+
657+
// Override fs/promises.watch
658+
originalPromisesWatch = fsPromises.watch;
659+
fsPromises.watch = function watch(filename, options) {
660+
if (typeof filename === 'string' || filename instanceof URL) {
661+
const pathStr = typeof filename === 'string' ? filename : filename.pathname;
662+
const vfsResult = findVFSForWatch(pathStr);
663+
if (vfsResult !== null) {
664+
return vfsResult.vfs.promises.watch(pathStr, options);
665+
}
666+
}
667+
return originalPromisesWatch.call(fsPromises, filename, options);
668+
};
669+
568670
// Register ESM hooks using Module.registerHooks
569671
Module.registerHooks({
570672
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+
* @throws {ERR_METHOD_NOT_IMPLEMENTED} When not overridden by subclass
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+
* @throws {ERR_METHOD_NOT_IMPLEMENTED} When not overridden by subclass
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+
* @throws {ERR_METHOD_NOT_IMPLEMENTED} When not overridden by subclass
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)