Skip to content

Commit 40ab909

Browse files
committed
vfs: add async glob support, experimental warning, and moduleHooks option
- Add hooks for fs/promises.readdir and fs/promises.lstat to enable async glob patterns (fs.glob callback and fs/promises.glob) - Emit experimental warning when fs.createVirtual() is called - Add moduleHooks option (default: true) to control whether require/import hooks are installed, allowing VFS to be used for fs operations only - Move require('internal/vfs/errors') to top of module_hooks.js - Update documentation to reflect async glob support and moduleHooks option - Remove "all tests passed" console.log statements from test files
1 parent 03b6f87 commit 40ab909

File tree

10 files changed

+174
-19
lines changed

10 files changed

+174
-19
lines changed

doc/api/fs.md

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8323,6 +8323,8 @@ added: REPLACEME
83238323
* `options` {Object}
83248324
* `fallthrough` {boolean} When `true`, operations on paths not in the VFS
83258325
fall through to the real file system. **Default:** `true`.
8326+
* `moduleHooks` {boolean} When `true`, enables hooks for `require()` and
8327+
`import` to load modules from the VFS. **Default:** `true`.
83268328
* Returns: {VirtualFileSystem}
83278329
83288330
Creates a new virtual file system instance.
@@ -8335,6 +8337,9 @@ const vfs = fs.createVirtual({ fallthrough: true });
83358337

83368338
// Create a VFS that only serves virtual files
83378339
const isolatedVfs = fs.createVirtual({ fallthrough: false });
8340+
8341+
// Create a VFS without module loading hooks (fs operations only)
8342+
const fsOnlyVfs = fs.createVirtual({ moduleHooks: false });
83388343
```
83398344
83408345
### Class: `VirtualFileSystem`
@@ -8661,7 +8666,8 @@ console.log(mod.default()); // 'Hello'
86618666
86628667
### Glob support
86638668
8664-
The VFS integrates with `fs.globSync()` when mounted or in overlay mode:
8669+
The VFS integrates with `fs.glob()`, `fs.globSync()`, and `fs/promises.glob()`
8670+
when mounted or in overlay mode:
86658671
86668672
```cjs
86678673
const fs = require('node:fs');
@@ -8672,10 +8678,21 @@ vfs.addFile('/src/utils.js', 'export const util = 1;');
86728678
vfs.addFile('/src/lib/helper.js', 'export const helper = 1;');
86738679
vfs.mount('/virtual');
86748680

8675-
// Glob patterns work with virtual files
8681+
// Sync glob
86768682
const files = fs.globSync('/virtual/src/**/*.js');
86778683
console.log(files);
86788684
// ['/virtual/src/index.js', '/virtual/src/utils.js', '/virtual/src/lib/helper.js']
8685+
8686+
// Async glob with callback
8687+
fs.glob('/virtual/src/*.js', (err, matches) => {
8688+
console.log(matches); // ['/virtual/src/index.js', '/virtual/src/utils.js']
8689+
});
8690+
8691+
// Async glob with promises (returns async iterator)
8692+
const { glob } = require('fs/promises');
8693+
for await (const file of glob('/virtual/src/**/*.js')) {
8694+
console.log(file);
8695+
}
86798696
```
86808697
86818698
### Limitations
@@ -8687,8 +8704,6 @@ The current VFS implementation has the following limitations:
86878704
* **No symbolic links**: Symbolic links are not supported.
86888705
* **No file watching**: `fs.watch()` and `fs.watchFile()` do not work with
86898706
virtual files.
8690-
* **Async glob**: Only `fs.globSync()` is supported. The async `fs.glob()` API
8691-
does not support VFS paths.
86928707
* **No real file descriptor**: Virtual file descriptors (10000+) are managed
86938708
separately from real file descriptors.
86948709

lib/internal/vfs/module_hooks.js

Lines changed: 89 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ const {
1010

1111
const { normalizePath } = require('internal/vfs/router');
1212
const { pathToFileURL, fileURLToPath } = require('internal/url');
13+
const { createENOENT } = require('internal/vfs/errors');
1314

1415
// Registry of active VFS instances
1516
const activeVFSList = [];
@@ -26,6 +27,10 @@ let originalLstatSync = null;
2627
let originalStatSync = null;
2728
// Original fs.readdirSync function
2829
let originalReaddirSync = null;
30+
// Original fs/promises.readdir function
31+
let originalPromisesReaddir = null;
32+
// Original fs/promises.lstat function
33+
let originalPromisesLstat = null;
2934
// Track if hooks are installed
3035
let hooksInstalled = false;
3136
// ESM hooks instance (for potential cleanup)
@@ -111,7 +116,6 @@ function findVFSForRead(filename, options) {
111116
} else if (vfs.isMounted) {
112117
// In mounted mode, if path is under mount point but doesn't exist,
113118
// don't fall through to real fs - throw ENOENT
114-
const { createENOENT } = require('internal/vfs/errors');
115119
throw createENOENT('open', filename);
116120
}
117121
}
@@ -139,7 +143,6 @@ function findVFSForRealpath(filename) {
139143
}
140144
}
141145
} else if (vfs.isMounted) {
142-
const { createENOENT } = require('internal/vfs/errors');
143146
throw createENOENT('realpath', filename);
144147
}
145148
}
@@ -167,7 +170,6 @@ function findVFSForFsStat(filename) {
167170
}
168171
}
169172
} else if (vfs.isMounted) {
170-
const { createENOENT } = require('internal/vfs/errors');
171173
throw createENOENT('stat', filename);
172174
}
173175
}
@@ -196,14 +198,68 @@ function findVFSForReaddir(dirname, options) {
196198
}
197199
}
198200
} else if (vfs.isMounted) {
199-
const { createENOENT } = require('internal/vfs/errors');
200201
throw createENOENT('scandir', dirname);
201202
}
202203
}
203204
}
204205
return null;
205206
}
206207

208+
/**
209+
* Async version: Checks all active VFS instances for readdir.
210+
* @param {string} dirname The directory path
211+
* @param {object} options The readdir options
212+
* @returns {Promise<{ vfs: VirtualFileSystem, entries: string[]|Dirent[] }|null>}
213+
*/
214+
async function findVFSForReaddirAsync(dirname, options) {
215+
const normalized = normalizePath(dirname);
216+
for (let i = 0; i < activeVFSList.length; i++) {
217+
const vfs = activeVFSList[i];
218+
if (vfs.shouldHandle(normalized)) {
219+
if (vfs.existsSync(normalized)) {
220+
try {
221+
const entries = await vfs.promises.readdir(normalized, options);
222+
return { vfs, entries };
223+
} catch (e) {
224+
if (vfs.isMounted) {
225+
throw e;
226+
}
227+
}
228+
} else if (vfs.isMounted) {
229+
throw createENOENT('scandir', dirname);
230+
}
231+
}
232+
}
233+
return null;
234+
}
235+
236+
/**
237+
* Async version: Checks all active VFS instances for lstat.
238+
* @param {string} filename The path to stat
239+
* @returns {Promise<{ vfs: VirtualFileSystem, stats: Stats }|null>}
240+
*/
241+
async function findVFSForLstatAsync(filename) {
242+
const normalized = normalizePath(filename);
243+
for (let i = 0; i < activeVFSList.length; i++) {
244+
const vfs = activeVFSList[i];
245+
if (vfs.shouldHandle(normalized)) {
246+
if (vfs.existsSync(normalized)) {
247+
try {
248+
const stats = await vfs.promises.lstat(normalized);
249+
return { vfs, stats };
250+
} catch (e) {
251+
if (vfs.isMounted) {
252+
throw e;
253+
}
254+
}
255+
} else if (vfs.isMounted) {
256+
throw createENOENT('lstat', filename);
257+
}
258+
}
259+
}
260+
return null;
261+
}
262+
207263
/**
208264
* Determine module format from file extension.
209265
* @param {string} url The file URL
@@ -444,6 +500,35 @@ function installHooks() {
444500
return originalReaddirSync.call(fs, path, options);
445501
};
446502

503+
// Hook fs/promises for async glob support
504+
const fsPromises = require('fs/promises');
505+
506+
// Override fs/promises.readdir (needed for async glob support)
507+
originalPromisesReaddir = fsPromises.readdir;
508+
fsPromises.readdir = async function vfsPromisesReaddir(path, options) {
509+
if (typeof path === 'string' || path instanceof URL) {
510+
const pathStr = typeof path === 'string' ? path : path.pathname;
511+
const vfsResult = await findVFSForReaddirAsync(pathStr, options);
512+
if (vfsResult !== null) {
513+
return vfsResult.entries;
514+
}
515+
}
516+
return originalPromisesReaddir.call(fsPromises, path, options);
517+
};
518+
519+
// Override fs/promises.lstat (needed for async glob support)
520+
originalPromisesLstat = fsPromises.lstat;
521+
fsPromises.lstat = async function vfsPromisesLstat(path, options) {
522+
if (typeof path === 'string' || path instanceof URL) {
523+
const pathStr = typeof path === 'string' ? path : path.pathname;
524+
const vfsResult = await findVFSForLstatAsync(pathStr);
525+
if (vfsResult !== null) {
526+
return vfsResult.stats;
527+
}
528+
}
529+
return originalPromisesLstat.call(fsPromises, path, options);
530+
};
531+
447532
// Register ESM hooks using Module.registerHooks
448533
esmHooksInstance = Module.registerHooks({
449534
resolve: vfsResolveHook,

lib/internal/vfs/virtual_fs.js

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ const {
4040
registerVFS,
4141
unregisterVFS,
4242
} = require('internal/vfs/module_hooks');
43+
const { emitExperimentalWarning } = require('internal/util');
4344
const {
4445
fs: {
4546
UV_DIRENT_FILE,
@@ -53,6 +54,7 @@ const kMountPoint = Symbol('kMountPoint');
5354
const kMounted = Symbol('kMounted');
5455
const kOverlay = Symbol('kOverlay');
5556
const kFallthrough = Symbol('kFallthrough');
57+
const kModuleHooks = Symbol('kModuleHooks');
5658
const kPromises = Symbol('kPromises');
5759

5860
/**
@@ -63,13 +65,16 @@ class VirtualFileSystem {
6365
/**
6466
* @param {object} [options] Configuration options
6567
* @param {boolean} [options.fallthrough=true] Whether to fall through to real fs on miss
68+
* @param {boolean} [options.moduleHooks=true] Whether to enable require/import hooks
6669
*/
6770
constructor(options = {}) {
71+
emitExperimentalWarning('fs.createVirtual');
6872
this[kRoot] = new VirtualDirectory('/');
6973
this[kMountPoint] = null;
7074
this[kMounted] = false;
7175
this[kOverlay] = false;
7276
this[kFallthrough] = options.fallthrough !== false;
77+
this[kModuleHooks] = options.moduleHooks !== false;
7378
this[kPromises] = null; // Lazy-initialized
7479
}
7580

@@ -229,7 +234,9 @@ class VirtualFileSystem {
229234
}
230235
this[kMountPoint] = normalizePath(prefix);
231236
this[kMounted] = true;
232-
registerVFS(this);
237+
if (this[kModuleHooks]) {
238+
registerVFS(this);
239+
}
233240
}
234241

235242
/**
@@ -240,7 +247,9 @@ class VirtualFileSystem {
240247
throw new Error('VFS is already mounted or in overlay mode');
241248
}
242249
this[kOverlay] = true;
243-
registerVFS(this);
250+
if (this[kModuleHooks]) {
251+
registerVFS(this);
252+
}
244253
}
245254

246255
/**

test/parallel/test-vfs-basic.js

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -213,4 +213,3 @@ const fs = require('fs');
213213
}, { code: 'ENOENT' });
214214
}
215215

216-
console.log('All VFS basic tests passed');

test/parallel/test-vfs-fd.js

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -317,4 +317,3 @@ const fs = require('fs');
317317
myVfs.closeSync(fd2);
318318
}
319319

320-
console.log('All VFS fd tests passed');

test/parallel/test-vfs-glob.js

Lines changed: 55 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,8 +57,61 @@ const fs = require('fs');
5757
myVfs.unmount();
5858
}
5959

60-
// Note: Async glob (fs.glob with callback) requires hooking fs/promises
61-
// which is not implemented yet. The globSync version works with VFS.
60+
// Test async glob (callback API) with VFS mounted directory
61+
{
62+
const myVfs = fs.createVirtual();
63+
myVfs.addFile('/async-src/index.js', 'export default 1;');
64+
myVfs.addFile('/async-src/utils.js', 'export const util = 1;');
65+
myVfs.addFile('/async-src/lib/helper.js', 'export const helper = 1;');
66+
myVfs.mount('/async-virtual');
67+
68+
fs.glob('/async-virtual/async-src/*.js', common.mustCall((err, files) => {
69+
assert.strictEqual(err, null);
70+
assert.strictEqual(files.length, 2);
71+
assert.ok(files.includes('/async-virtual/async-src/index.js'));
72+
assert.ok(files.includes('/async-virtual/async-src/utils.js'));
73+
74+
// Test recursive pattern with callback
75+
fs.glob('/async-virtual/async-src/**/*.js', common.mustCall((err, allFiles) => {
76+
assert.strictEqual(err, null);
77+
assert.strictEqual(allFiles.length, 3);
78+
assert.ok(allFiles.includes('/async-virtual/async-src/index.js'));
79+
assert.ok(allFiles.includes('/async-virtual/async-src/utils.js'));
80+
assert.ok(allFiles.includes('/async-virtual/async-src/lib/helper.js'));
81+
82+
myVfs.unmount();
83+
}));
84+
}));
85+
}
86+
87+
// Test async glob (promise API) with VFS
88+
(async () => {
89+
const myVfs = fs.createVirtual();
90+
myVfs.addFile('/promise-src/a.ts', 'const a = 1;');
91+
myVfs.addFile('/promise-src/b.ts', 'const b = 2;');
92+
myVfs.addFile('/promise-src/c.js', 'const c = 3;');
93+
myVfs.mount('/promise-virtual');
94+
95+
const { glob } = require('fs/promises');
96+
97+
// glob returns an async iterator, need to collect results
98+
const tsFiles = [];
99+
for await (const file of glob('/promise-virtual/promise-src/*.ts')) {
100+
tsFiles.push(file);
101+
}
102+
assert.strictEqual(tsFiles.length, 2);
103+
assert.ok(tsFiles.includes('/promise-virtual/promise-src/a.ts'));
104+
assert.ok(tsFiles.includes('/promise-virtual/promise-src/b.ts'));
105+
106+
// Test multiple patterns
107+
const allFiles = [];
108+
for await (const file of glob(['/promise-virtual/promise-src/*.ts', '/promise-virtual/promise-src/*.js'])) {
109+
allFiles.push(file);
110+
}
111+
assert.strictEqual(allFiles.length, 3);
112+
113+
myVfs.unmount();
114+
})().then(common.mustCall());
62115

63116
// Test glob with withFileTypes option
64117
{
@@ -142,4 +195,3 @@ const fs = require('fs');
142195
myVfs.unmount();
143196
}
144197

145-
console.log('All VFS glob tests passed');

test/parallel/test-vfs-import.mjs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,4 +146,3 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
146146
myVfs.unmount();
147147
}
148148

149-
console.log('All VFS import tests passed');

test/parallel/test-vfs-promises.js

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -298,4 +298,3 @@ const fs = require('fs');
298298
assert.strictEqual(data2, 'count: 2');
299299
})().then(common.mustCall());
300300

301-
console.log('All VFS promises tests passed');

test/parallel/test-vfs-require.js

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -204,4 +204,3 @@ const fs = require('fs');
204204
}, { code: 'MODULE_NOT_FOUND' });
205205
}
206206

207-
console.log('All VFS require tests passed');

test/parallel/test-vfs-streams.js

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -233,4 +233,3 @@ const fs = require('fs');
233233
}));
234234
}
235235

236-
console.log('All VFS streams tests passed');

0 commit comments

Comments
 (0)