Skip to content

Commit 12c47b5

Browse files
committed
vfs: integrate with CJS and ESM module loaders
Route loader fs and package.json operations through toggleable wrappers so the VFS can resolve and load modules from mounted paths. When no VFS is mounted, the wrappers take a null-check fast path with zero overhead. Hooks: - loaderStat / loaderReadFile / toRealPath / loaderLegacyMainResolve / loaderGetFormatOfExtensionlessFile in lib/internal/modules/helpers.js, consumed by cjs/loader.js, esm/resolve.js, esm/load.js and esm/get_format.js. - loaderReadPackageJSON / loaderGetNearestParentPackageJSON / loaderGetPackageScopeConfig / loaderGetPackageType, consumed by package_json_reader.js. - setLoaderFsOverrides / setLoaderPackageOverrides install / clear all hooks; clearRealpathCache exposes the helpers.js realpath cache so deregister can flush it. lib/internal/vfs/setup.js installs the overrides on first registerVFS and clears every JS-side loader cache (CJS _pathCache, CJS stat cache, realpath cache, package.json cache) on every deregister. The overrides themselves are uninstalled when the last VFS is removed so the fast path is fully restored. legacyMainResolve / extensionless-format behavior matches the C++ binding; package.json validation matches src/node_modules.cc (silently omit non-string main, throw on non-string name/type, etc). The "DO NOT depend on patchability" warnings in esm/load.js and esm/resolve.js are preserved and now point at node:vfs and module.registerHooks() as the formal hook mechanisms. Tests cover require / import / module-hooks / package.json / cache invalidation / cleanup-cycle scenarios under --experimental-vfs. Signed-off-by: Matteo Collina <hello@matteocollina.com>
1 parent 39b481b commit 12c47b5

15 files changed

Lines changed: 2380 additions & 43 deletions

doc/api/vfs.md

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,11 @@ callback-based, and promise-based file system methods that mirror the
4646
shape of the [`node:fs`][] API. All paths are POSIX-style and absolute
4747
(starting with `/`).
4848

49+
By default, the file tree is private to the VFS instance. To expose
50+
it through the global `node:fs` module, `require()`, and `import`,
51+
call [`vfs.mount(prefix)`][]; call [`vfs.unmount()`][] (or rely on a
52+
`using` declaration) to detach again.
53+
4954
## `vfs.create([provider][, options])`
5055

5156
<!-- YAML
@@ -92,6 +97,93 @@ added: REPLACEME
9297
* `emitExperimentalWarning` {boolean} Whether to emit the experimental
9398
warning. **Default:** `true`.
9499

100+
### `vfs.mount(prefix)`
101+
102+
<!-- YAML
103+
added: REPLACEME
104+
-->
105+
106+
* `prefix` {string} The path prefix where the VFS will be mounted.
107+
* Returns: {VirtualFileSystem} The VFS instance, for chaining or `using`.
108+
109+
Mounts the virtual file system at the specified path prefix. After
110+
mounting, files in the VFS can be accessed through the `node:fs`
111+
module — and resolved through `require()` and `import` — using paths
112+
that start with the prefix.
113+
114+
If a real file-system path already exists at the mount prefix, the
115+
VFS **shadows** that path: every operation against a path under the
116+
mount point is directed to the VFS until the VFS is unmounted.
117+
118+
```cjs
119+
const vfs = require('node:vfs');
120+
const fs = require('node:fs');
121+
122+
const myVfs = vfs.create();
123+
myVfs.writeFileSync('/data.txt', 'Hello');
124+
myVfs.mount('/virtual');
125+
126+
fs.readFileSync('/virtual/data.txt', 'utf8'); // 'Hello'
127+
```
128+
129+
Each `VirtualFileSystem` instance may be mounted at most once at a
130+
time. Attempting to mount an already-mounted instance throws
131+
`ERR_INVALID_STATE`. Mounting two instances at overlapping prefixes
132+
(e.g., `/virtual` and `/virtual/sub`) also throws `ERR_INVALID_STATE`.
133+
134+
The VFS supports the [Explicit Resource Management][] proposal. Use
135+
a `using` declaration to unmount automatically when leaving scope:
136+
137+
```cjs
138+
const vfs = require('node:vfs');
139+
const fs = require('node:fs');
140+
141+
{
142+
using myVfs = vfs.create();
143+
myVfs.writeFileSync('/data.txt', 'Hello');
144+
myVfs.mount('/virtual');
145+
146+
fs.readFileSync('/virtual/data.txt', 'utf8'); // 'Hello'
147+
} // VFS is automatically unmounted here
148+
149+
fs.existsSync('/virtual/data.txt'); // false
150+
```
151+
152+
### `vfs.unmount()`
153+
154+
<!-- YAML
155+
added: REPLACEME
156+
-->
157+
158+
Unmounts the virtual file system. After unmounting, virtual files
159+
are no longer reachable through `node:fs`, `require()`, or `import`.
160+
The same instance may be mounted again, at the same or a different
161+
prefix, by calling `mount()`.
162+
163+
This method is idempotent: calling `unmount()` on a VFS that is not
164+
currently mounted has no effect.
165+
166+
### `vfs.mounted`
167+
168+
<!-- YAML
169+
added: REPLACEME
170+
-->
171+
172+
* {boolean}
173+
174+
`true` while the VFS is mounted; `false` otherwise.
175+
176+
### `vfs.mountPoint`
177+
178+
<!-- YAML
179+
added: REPLACEME
180+
-->
181+
182+
* {string | null}
183+
184+
The current mount-point path as an absolute string, or `null` when
185+
the VFS is not mounted.
186+
95187
### `vfs.provider`
96188

97189
<!-- YAML
@@ -302,9 +394,12 @@ fields use synthetic but stable values:
302394
* `blocks` is `Math.ceil(size / 512)`.
303395
* Times default to the moment the entry was created/last modified.
304396

397+
[Explicit Resource Management]: https://github.com/tc39/proposal-explicit-resource-management
305398
[`MemoryProvider`]: #class-memoryprovider
306399
[`VirtualFileSystem`]: #class-virtualfilesystem
307400
[`VirtualProvider`]: #class-virtualprovider
308401
[`fs.BigIntStats`]: fs.md#class-fsbigintstats
309402
[`fs.Stats`]: fs.md#class-fsstats
310403
[`node:fs`]: fs.md
404+
[`vfs.mount(prefix)`]: #vfsmountprefix
405+
[`vfs.unmount()`]: #vfsunmount

lib/internal/modules/cjs/loader.js

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ const kFormat = Symbol('kFormat');
113113

114114
// Set first due to cycle with ESM loader functions.
115115
module.exports = {
116+
clearStatCache,
116117
kModuleSource,
117118
kModuleExport,
118119
kModuleExportNames,
@@ -155,14 +156,14 @@ const {
155156
} = internalBinding('contextify');
156157

157158
const assert = require('internal/assert');
158-
const fs = require('fs');
159159
const path = require('path');
160-
const internalFsBinding = internalBinding('fs');
161160
const { safeGetenv } = internalBinding('credentials');
162161
const {
163162
getCjsConditions,
164163
getCjsConditionsArray,
165164
initializeCjsConditions,
165+
loaderReadFile,
166+
loaderStat,
166167
loadBuiltinModule,
167168
makeRequireFunction,
168169
setHasStartedUserCJSExecution,
@@ -272,14 +273,24 @@ function stat(filename) {
272273
const result = statCache.get(filename);
273274
if (result !== undefined) { return result; }
274275
}
275-
const result = internalFsBinding.internalModuleStat(filename);
276+
const result = loaderStat(filename);
276277
if (statCache !== null && result >= 0) {
277278
// Only set cache when `internalModuleStat(filename)` succeeds.
278279
statCache.set(filename, result);
279280
}
280281
return result;
281282
}
282283

284+
/**
285+
* Clear the stat cache. Called when VFS instances are unmounted
286+
* to prevent stale stat results from being returned.
287+
*/
288+
function clearStatCache() {
289+
if (statCache !== null) {
290+
statCache = new SafeMap();
291+
}
292+
}
293+
283294
let _stat = stat;
284295
ObjectDefineProperty(Module, '_stat', {
285296
__proto__: null,
@@ -1201,7 +1212,7 @@ function defaultLoadImpl(filename, format) {
12011212
case 'module-typescript':
12021213
case 'commonjs-typescript':
12031214
case 'typescript': {
1204-
return fs.readFileSync(filename, 'utf8');
1215+
return loaderReadFile(filename, 'utf8');
12051216
}
12061217
case 'builtin':
12071218
return null;

lib/internal/modules/esm/get_format.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ const {
1010
} = primordials;
1111
const { getOptionValue } = require('internal/options');
1212
const { getValidatedPath } = require('internal/fs/utils');
13-
const fsBindings = internalBinding('fs');
1413
const { internal: internalConstants } = internalBinding('constants');
1514

1615
const extensionFormatMap = {
@@ -59,7 +58,8 @@ function mimeToFormat(mime) {
5958
*/
6059
function getFormatOfExtensionlessFile(url) {
6160
const path = getValidatedPath(url);
62-
switch (fsBindings.getFormatOfExtensionlessFile(path)) {
61+
const { loaderGetFormatOfExtensionlessFile } = require('internal/modules/helpers');
62+
switch (loaderGetFormatOfExtensionlessFile(path)) {
6363
case internalConstants.EXTENSIONLESS_FORMAT_WASM:
6464
return 'wasm';
6565
default:

lib/internal/modules/esm/load.js

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ const {
99

1010
const { defaultGetFormat } = require('internal/modules/esm/get_format');
1111
const { validateAttributes, emitImportAssertionWarning } = require('internal/modules/esm/assert');
12-
const fs = require('fs');
12+
const { loaderReadFile } = require('internal/modules/helpers');
1313

1414
const { Buffer: { from: BufferFrom } } = require('buffer');
1515

@@ -34,11 +34,13 @@ function getSourceSync(url, context) {
3434
const responseURL = href;
3535
let source;
3636
if (protocol === 'file:') {
37-
// If you are reading this code to figure out how to patch Node.js module loading
38-
// behavior - DO NOT depend on the patchability in new code: Node.js
37+
// If you are reading this code to figure out how to patch Node.js module
38+
// loading behavior - DO NOT depend on the patchability in new code: Node.js
3939
// internals may stop going through the JavaScript fs module entirely.
40-
// Prefer module.registerHooks() or other more formal fs hooks released in the future.
41-
source = fs.readFileSync(url);
40+
// Prefer module.registerHooks(), node:vfs, or other more formal fs hooks
41+
// released in the future. loaderReadFile is the toggleable hook used by
42+
// node:vfs and is not part of the public API.
43+
source = loaderReadFile(url);
4244
} else if (protocol === 'data:') {
4345
const result = dataURLProcessor(url);
4446
if (result === 'failure') {

lib/internal/modules/esm/resolve.js

Lines changed: 9 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ const {
99
ObjectPrototypeHasOwnProperty,
1010
RegExpPrototypeExec,
1111
RegExpPrototypeSymbolReplace,
12-
SafeMap,
1312
SafeSet,
1413
String,
1514
StringPrototypeEndsWith,
@@ -23,16 +22,13 @@ const {
2322
encodeURIComponent,
2423
} = primordials;
2524
const assert = require('internal/assert');
26-
const internalFS = require('internal/fs/utils');
2725
const { BuiltinModule } = require('internal/bootstrap/realm');
28-
const fs = require('fs');
2926
const { getOptionValue } = require('internal/options');
3027
// Do not eagerly grab .manifest, it may be in TDZ
3128
const { sep, posix: { relative: relativePosixPath }, resolve } = require('path');
3229
const { URL, pathToFileURL, fileURLToPath, isURL, URLParse } = require('internal/url');
3330
const { getCWDURL, setOwnProperty } = require('internal/util');
3431
const { canParse: URLCanParse } = internalBinding('url');
35-
const { legacyMainResolve: FSLegacyMainResolve } = internalBinding('fs');
3632
const {
3733
ERR_INPUT_TYPE_NOT_ALLOWED,
3834
ERR_INVALID_ARG_TYPE,
@@ -49,7 +45,7 @@ const {
4945
const { defaultGetFormatWithoutErrors } = require('internal/modules/esm/get_format');
5046
const { getConditionsSet } = require('internal/modules/esm/utils');
5147
const packageJsonReader = require('internal/modules/package_json_reader');
52-
const internalFsBinding = internalBinding('fs');
48+
const { loaderLegacyMainResolve, loaderStat, toRealPath } = require('internal/modules/helpers');
5349

5450
/**
5551
* @typedef {import('internal/modules/esm/package_config.js').PackageConfig} PackageConfig
@@ -149,8 +145,6 @@ function emitLegacyIndexDeprecation(url, path, pkgPath, base, main) {
149145
}
150146
}
151147

152-
const realpathCache = new SafeMap();
153-
154148
const legacyMainResolveExtensions = [
155149
'',
156150
'.js',
@@ -198,7 +192,7 @@ function legacyMainResolve(packageJSONUrl, packageConfig, base) {
198192

199193
const baseStringified = isURL(base) ? base.href : base;
200194

201-
const resolvedOption = FSLegacyMainResolve(pkgPath, packageConfig.main, baseStringified);
195+
const resolvedOption = loaderLegacyMainResolve(pkgPath, packageConfig.main, baseStringified);
202196

203197
const maybeMain = resolvedOption <= legacyMainResolveExtensionsIndexes.kResolvedByMainIndexNode ?
204198
packageConfig.main || './' : '';
@@ -244,7 +238,7 @@ function finalizeResolution(resolved, base, preserveSymlinks) {
244238
throw err;
245239
}
246240

247-
const stats = internalFsBinding.internalModuleStat(
241+
const stats = loaderStat(
248242
StringPrototypeEndsWith(path, '/') ? StringPrototypeSlice(path, -1) : path,
249243
);
250244

@@ -273,13 +267,13 @@ function finalizeResolution(resolved, base, preserveSymlinks) {
273267
}
274268

275269
if (!preserveSymlinks) {
276-
// If you are reading this code to figure out how to patch Node.js module loading
277-
// behavior - DO NOT depend on the patchability in new code: Node.js
270+
// If you are reading this code to figure out how to patch Node.js module
271+
// loading behavior - DO NOT depend on the patchability in new code: Node.js
278272
// internals may stop going through the JavaScript fs module entirely.
279-
// Prefer module.registerHooks() or other more formal fs hooks released in the future.
280-
const real = fs.realpathSync(path, {
281-
[internalFS.realpathCacheKey]: realpathCache,
282-
});
273+
// Prefer module.registerHooks(), node:vfs, or other more formal fs hooks
274+
// released in the future. toRealPath is the toggleable hook used by
275+
// node:vfs and is not part of the public API.
276+
const real = toRealPath(path);
283277
const { search, hash } = resolved;
284278
resolved =
285279
pathToFileURL(real + (StringPrototypeEndsWith(path, sep) ? '/' : ''));

0 commit comments

Comments
 (0)