Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
66713f6
vfs: integrate with CJS and ESM module loaders
mcollina Jun 16, 2026
90d8eb4
vfs: fix module loader paths on Windows
mcollina Jun 26, 2026
b28a077
vfs: scope-purge loader caches via per-VFS owned-keys sets
mcollina Jun 26, 2026
d11997b
vfs: always allocate a fresh stat cache in clearStatCache
mcollina Jun 28, 2026
f94cdbe
vfs: anchor vfs-layer URL tag match in urlBelongsToLayer
mcollina Jun 29, 2026
ee06f2e
vfs: drop cache-busting/HMR reference from vfs-layer tag comment
mcollina Jun 29, 2026
7bedf50
benchmark: add VFS fs-dispatch overhead bench
mcollina Jun 29, 2026
133a8a1
vfs: hoist path normalization out of dispatch loop
mcollina Jun 29, 2026
cefef62
vfs: mount inside a reserved namespace
mcollina Jul 3, 2026
722e10d
test: fix VFS module tests on Windows
mcollina Jul 7, 2026
e7dd654
doc: clarify VFS ESM imports on Windows
mcollina Jul 7, 2026
de17e81
doc: remove em dashes from VFS docs
mcollina Jul 7, 2026
a17ee29
vfs: drop mount() prefix argument and layer- path segment
mcollina Jul 7, 2026
023516e
Update lib/internal/vfs/setup.js
mcollina Jul 13, 2026
e378435
Update lib/internal/vfs/setup.js
mcollina Jul 13, 2026
7e94c3d
vfs: drop unused shouldHandle / router exports
mcollina Jul 13, 2026
2203b59
vfs: use internal EXTENSIONLESS_FORMAT_* constants
mcollina Jul 13, 2026
f2f7b04
vfs: raise ERR_INVALID_PACKAGE_CONFIG for CJS too
mcollina Jul 13, 2026
2462d17
vfs: default all read errors in getFormatOfExtensionlessFile to JS
mcollina Jul 13, 2026
902254d
vfs: return normalized path from findVFS
mcollina Jul 13, 2026
2716996
vfs: inline isUnderMountPoint and use shouldHandleNormalized
mcollina Jul 13, 2026
6fafe07
vfs,esm: share legacyMainResolveExtensions arrays
mcollina Jul 13, 2026
6e3f1b9
vfs: add cleanForVfsPrefix helper for cache purges
mcollina Jul 13, 2026
a95717a
vfs: use indexed for loops in cleanForVfsPrefix
mcollina Jul 13, 2026
ffd68a2
Revert "vfs: use indexed for loops in cleanForVfsPrefix"
mcollina Jul 13, 2026
7b6e3de
vfs: fold loader wrappers into wrapLoaderMethod factory
mcollina Jul 13, 2026
2e463ca
vfs: fix lint on wrapLoaderMethod curly braces
mcollina Jul 13, 2026
5b71a94
vfs: throw ERR_INVALID_PACKAGE_CONFIG on malformed ancestor pjson
mcollina Jul 13, 2026
30d5115
src: split GetPackageJSON and expose parsePackageJSON binding
mcollina Jul 13, 2026
337c130
vfs: use native parsePackageJSON binding, drop serializePackageJSON
mcollina Jul 13, 2026
0d5fb61
vfs: read pjson as Buffer, skip UTF-8 decode
mcollina Jul 13, 2026
0a98b98
vfs: strip verbose comments across the PR
mcollina Jul 18, 2026
4c417d0
vfs: unexpose layerId property
mcollina Jul 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions benchmark/vfs/bench-fs-dispatch.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
'use strict';

// Measures VFS dispatch overhead on fs ops against real-filesystem paths
// (paths no VFS claims). Layers=0 means the VFS module is never loaded.

const common = require('../common.js');
const fs = require('fs');
const path = require('path');

const bench = common.createBenchmark(main, {
n: [3e5],
op: ['statSync', 'existsSync', 'accessSync', 'readFileSync'],
layers: [0, 1, 2, 5, 10],
}, {
flags: ['--experimental-vfs', '--no-warnings'],
});

function mountLayers(count) {
const vfs = require('node:vfs');
const handles = [];
for (let i = 0; i < count; i++) {
const v = vfs.create();
v.mount();
handles.push(v);
}
return handles;
}

function main({ n, op, layers }) {
const handles = layers > 0 ? mountLayers(layers) : null;

const target = layers === 0 ? __filename : path.join(__dirname, path.basename(__filename));

for (let i = 0; i < 1000; i++) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this is actually useful, do real world app really go through 1000+ calls for all these to hit the optimizing compiler? If not this is over fitting. It's much more realistic to at least e.g. benchmark the module loading performance of a big graph after mounting the VFS.

if (op === 'statSync') fs.statSync(target);
else if (op === 'existsSync') fs.existsSync(target);
else if (op === 'accessSync') fs.accessSync(target);
else fs.readFileSync(target);
}

bench.start();
if (op === 'statSync') {
for (let i = 0; i < n; i++) fs.statSync(target);
} else if (op === 'existsSync') {
for (let i = 0; i < n; i++) fs.existsSync(target);
} else if (op === 'accessSync') {
for (let i = 0; i < n; i++) fs.accessSync(target);
} else {
for (let i = 0; i < n; i++) fs.readFileSync(target);
}
bench.end(n);

if (handles) {
for (const v of handles) v.unmount();
}
}
167 changes: 167 additions & 0 deletions doc/api/vfs.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,11 @@ callback-based, and promise-based file system methods that mirror the
shape of the [`node:fs`][] API. All paths are POSIX-style and absolute
(starting with `/`).

By default, the file tree is private to the VFS instance. To expose
it through the global `node:fs` module, `require()`, and `import`,
call [`vfs.mount()`][]; call [`vfs.unmount()`][] (or rely on a
`using` declaration) to detach again.

## `vfs.create([provider][, options])`

<!-- YAML
Expand Down Expand Up @@ -107,6 +112,99 @@ added: v26.4.0
* `emitExperimentalWarning` {boolean} Whether to emit the experimental
warning. **Default:** `true`.

### `vfs.mount()`

<!-- YAML
added: REPLACEME
-->

* Returns: {string} The absolute mount point.

Mounts the virtual file system and returns the resulting mount point.
After mounting, files in the VFS can be accessed through the
`node:fs` module and resolved through `require()` and `import`
using paths under the returned mount point.

Mount points always live inside a reserved namespace,
`${os.devNull}/vfs/<layerId>`. Because [`os.devNull`][] is a
character device (POSIX) or a device-namespace path (Windows) that
cannot have child file system entries, no real file-system path can
exist under this namespace: virtual paths never conflate with (or
shadow) real paths, and the layer that owns a path is visible in the
path itself.
Comment on lines +128 to +134

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
Mount points always live inside a reserved namespace,
`${os.devNull}/vfs/<layerId>`. Because [`os.devNull`][] is a
character device (POSIX) or a device-namespace path (Windows) that
cannot have child file system entries, no real file-system path can
exist under this namespace: virtual paths never conflate with (or
shadow) real paths, and the layer that owns a path is visible in the
path itself.
Mount points always live inside a reserved namespace that cannot have child file system entries,
so virtual paths never conflate with (or shadow) real paths. The virtual path scheme is subject to
change and users should not manually construct them based on assumptions. Instead, obtain
them from what `vfs.mount()` returns or `vfs.mountPoint`.

I don't think we should make the path scheme a contract.


```cjs
const vfs = require('node:vfs');
const fs = require('node:fs');

const myVfs = vfs.create();
myVfs.writeFileSync('/data.txt', 'Hello');
const mountPoint = myVfs.mount();
// e.g. '/dev/null/vfs/0'

fs.readFileSync(`${mountPoint}/data.txt`, 'utf8'); // 'Hello'
```

Each `VirtualFileSystem` instance may be mounted at most once at a
time. Attempting to mount an already-mounted instance throws
`ERR_INVALID_STATE`. Because each instance mounts inside its own
per-layer namespace, mounts from different instances can never
overlap.

The VFS supports the [Explicit Resource Management][] proposal. Use
a `using` declaration to unmount automatically when leaving scope:

```cjs
const vfs = require('node:vfs');
const fs = require('node:fs');

let mountPoint;
{
using myVfs = vfs.create();
myVfs.writeFileSync('/data.txt', 'Hello');
mountPoint = myVfs.mount();

fs.readFileSync(`${mountPoint}/data.txt`, 'utf8'); // 'Hello'
} // VFS is automatically unmounted here

fs.existsSync(`${mountPoint}/data.txt`); // false
```

### `vfs.unmount()`

<!-- YAML
added: REPLACEME
-->

Unmounts the virtual file system. After unmounting, virtual files
are no longer reachable through `node:fs`, `require()`, or `import`.
The same instance may be mounted again by calling `mount()`.

This method is idempotent: calling `unmount()` on a VFS that is not
currently mounted has no effect.

### `vfs.mounted`

<!-- YAML
added: REPLACEME
-->

* {boolean}

`true` while the VFS is mounted; `false` otherwise.

### `vfs.mountPoint`

<!-- YAML
added: REPLACEME
-->

* {string | null}

The current mount point as an absolute string (the value returned by
the last [`vfs.mount()`][] call), or `null` when the VFS is not
mounted.

### `vfs.provider`

<!-- YAML
Expand Down Expand Up @@ -195,6 +293,71 @@ The promise namespace mirrors `fs.promises` and includes `readFile`,
`access`, `rm`, `truncate`, `link`, `mkdtemp`, `chmod`, `chown`, `lchown`,
`utimes`, `lutimes`, `open`, `lchmod`, and `watch`.

## Module loader integration

Once a `VirtualFileSystem` is mounted, paths under the mount point
participate in module resolution and loading. Both
`require()` / `require.resolve()` (CommonJS) and `import` /
`import.meta.resolve()` (ECMAScript modules) consult the VFS through
the same toggleable hooks that `node:fs` uses, so files served from
the VFS are first-class modules: `package.json` is honoured,
extensionless files are sniffed for Wasm vs. JavaScript, conditional
`exports` / `imports` work, and so on.
Comment on lines +299 to +305

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
participate in module resolution and loading. Both
`require()` / `require.resolve()` (CommonJS) and `import` /
`import.meta.resolve()` (ECMAScript modules) consult the VFS through
the same toggleable hooks that `node:fs` uses, so files served from
the VFS are first-class modules: `package.json` is honoured,
extensionless files are sniffed for Wasm vs. JavaScript, conditional
`exports` / `imports` work, and so on.
participate in module resolution and loading. Both
`require()` / `require.resolve()` (CommonJS) and `import` /
`import.meta.resolve()` (ECMAScript modules) consult the VFS through
the same toggleable hooks that `node:fs` uses, so files served from
the VFS are first-class modules: `package.json` is honoured,
extensionless files are sniffed for Wasm vs. JavaScript, conditional
`exports` / `imports` work, and so on.

This paragraph is listing example without actually clarifying anything - exactly how do they appear in the module lookup algorithm, and with what precedence?


```cjs
const vfs = require('node:vfs');

const myVfs = vfs.create();
myVfs.mkdirSync('/lib');
myVfs.writeFileSync('/lib/greet.js', 'module.exports = () => "hi";');
myVfs.writeFileSync(
'/lib/package.json', '{"main": "./greet.js"}');
const mountPoint = myVfs.mount();

const greet = require(`${mountPoint}/lib`);
console.log(greet()); // 'hi'

myVfs.unmount();
```

For ECMAScript modules, convert mounted paths to `file:` URLs before
passing them to dynamic `import()`. This keeps VFS imports portable on
Windows, where mounted paths use Windows path syntax.

```mjs
import { pathToFileURL } from 'node:url';
import vfs from 'node:vfs';

const myVfs = vfs.create();
myVfs.writeFileSync('/mod.mjs', 'export const value = 42;');
const mountPoint = myVfs.mount();

const { value } = await import(
pathToFileURL(`${mountPoint}/mod.mjs`).href,
);
console.log(value); // 42

myVfs.unmount();
```

Module identity follows the path: `__filename` and `module.filename`
are the plain absolute path of the module under the mount point, and
`import.meta.url` is the corresponding `file:` URL, with no synthetic
decorations. Importing the same virtual path repeatedly, including
through `import.meta.resolve()`, yields the same module instance,
exactly as for real files.

Calling [`vfs.unmount()`][] invalidates the modules that were loaded
from the mount point: a subsequent `require()` or `import` of a path
under a re-created mount re-reads the file from the newly mounted
VFS rather than returning a stale module. Modules loaded from other
VFS instances or from the real file system are unaffected.

Mounting and unmounting do not invalidate ESM modules that are
already executing. As with any other module-system teardown,
unmounting a VFS while the import graph below it is still loading is
the caller's responsibility to avoid.

## Class: `VirtualProvider`

<!-- YAML
Expand Down Expand Up @@ -318,10 +481,14 @@ fields use synthetic but stable values:
* `blocks` is `Math.ceil(size / 512)`.
* Times default to the moment the entry was created/last modified.

[Explicit Resource Management]: https://github.com/tc39/proposal-explicit-resource-management
[`MemoryProvider`]: #class-memoryprovider
[`RealFSProvider`]: #class-realfsprovider
[`VirtualFileSystem`]: #class-virtualfilesystem
[`VirtualProvider`]: #class-virtualprovider
[`fs.BigIntStats`]: fs.md#class-fsbigintstats
[`fs.Stats`]: fs.md#class-fsstats
[`node:fs`]: fs.md
[`os.devNull`]: os.md#osdevnull
[`vfs.mount()`]: #vfsmount
[`vfs.unmount()`]: #vfsunmount
18 changes: 14 additions & 4 deletions lib/fs.js
Original file line number Diff line number Diff line change
Expand Up @@ -2044,8 +2044,13 @@ function fstatSync(fd, options = { __proto__: null, bigint: false }) {
function lstatSync(path, options = { __proto__: null, bigint: false, throwIfNoEntry: true }) {
const h = vfsState.handlers;
if (h !== null) {
const result = h.lstatSync(path, options);
if (result !== undefined) return result;
try {
const result = h.lstatSync(path, options);
if (result !== undefined) return result;
} catch (err) {
if (err?.code === 'ENOENT' && options?.throwIfNoEntry === false) return;
throw err;
}
}
path = getValidatedPath(path);
if (permission.isEnabled() && !permission.has('fs.read', path)) {
Expand Down Expand Up @@ -2078,8 +2083,13 @@ function lstatSync(path, options = { __proto__: null, bigint: false, throwIfNoEn
function statSync(path, options = { __proto__: null, bigint: false, throwIfNoEntry: true }) {
const h = vfsState.handlers;
if (h !== null) {
const result = h.statSync(path, options);
if (result !== undefined) return result;
try {
const result = h.statSync(path, options);
if (result !== undefined) return result;
} catch (err) {
if (err?.code === 'ENOENT' && options?.throwIfNoEntry === false) return undefined;
throw err;
}
}
const stats = binding.stat(
getValidatedPath(path),
Expand Down
22 changes: 18 additions & 4 deletions lib/internal/modules/cjs/loader.js
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ const kFormat = Symbol('kFormat');

// Set first due to cycle with ESM loader functions.
module.exports = {
purgeModuleCachesForPrefix,
kModuleSource,
kModuleExport,
kModuleExportNames,
Expand Down Expand Up @@ -155,14 +156,15 @@ const {
} = internalBinding('contextify');

const assert = require('internal/assert');
const fs = require('fs');
const path = require('path');
const internalFsBinding = internalBinding('fs');
const { safeGetenv } = internalBinding('credentials');
const {
cleanForVfsPrefix,
getCjsConditions,
getCjsConditionsArray,
initializeCjsConditions,
loaderReadFile,
loaderStat,
loadBuiltinModule,
makeRequireFunction,
setHasStartedUserCJSExecution,
Expand Down Expand Up @@ -278,14 +280,26 @@ function stat(filename) {
const result = statCache.get(filename);
if (result !== undefined) { return result; }
}
const result = internalFsBinding.internalModuleStat(filename);
const result = loaderStat(filename);
if (statCache !== null && result >= 0) {
// Only set cache when `internalModuleStat(filename)` succeeds.
statCache.set(filename, result);
}
return result;
}

/**
* Drop Module._cache, Module._pathCache and statCache entries under
* `mountPoint`. Called when a VFS is unmounted.
* @param {string} mountPoint Absolute mount-point path
*/
function purgeModuleCachesForPrefix(mountPoint) {
cleanForVfsPrefix(Module._cache, mountPoint);
// Module._pathCache keys are `request\0parent` tokens; match on value.
cleanForVfsPrefix(Module._pathCache, mountPoint, true);
if (statCache !== null) { cleanForVfsPrefix(statCache, mountPoint); }
}

let _stat = stat;
ObjectDefineProperty(Module, '_stat', {
__proto__: null,
Expand Down Expand Up @@ -1248,7 +1262,7 @@ function defaultLoadImpl(filename, format) {
case 'module-typescript':
case 'commonjs-typescript':
case 'typescript': {
return fs.readFileSync(filename, 'utf8');
return loaderReadFile(filename, 'utf8');
}
case 'builtin':
return null;
Expand Down
4 changes: 2 additions & 2 deletions lib/internal/modules/esm/get_format.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ const {
} = primordials;
const { getOptionValue } = require('internal/options');
const { getValidatedPath } = require('internal/fs/utils');
const fsBindings = internalBinding('fs');
const { internal: internalConstants } = internalBinding('constants');

const extensionFormatMap = {
Expand Down Expand Up @@ -66,7 +65,8 @@ function mimeToFormat(mime) {
*/
function getFormatOfExtensionlessFile(url) {
const path = getValidatedPath(url);
switch (fsBindings.getFormatOfExtensionlessFile(path)) {
const { loaderGetFormatOfExtensionlessFile } = require('internal/modules/helpers');
switch (loaderGetFormatOfExtensionlessFile(path)) {
case internalConstants.EXTENSIONLESS_FORMAT_WASM:
return 'wasm';
default:
Expand Down
4 changes: 2 additions & 2 deletions lib/internal/modules/esm/load.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ const {

const { defaultGetFormat } = require('internal/modules/esm/get_format');
const { validateAttributes, emitImportAssertionWarning } = require('internal/modules/esm/assert');
const fs = require('fs');
const { loaderReadFile } = require('internal/modules/helpers');

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

Expand Down Expand Up @@ -38,7 +38,7 @@ function getSourceSync(url, context) {
// behavior - DO NOT depend on the patchability in new code: Node.js
// internals may stop going through the JavaScript fs module entirely.
// Prefer module.registerHooks() or other more formal fs hooks released in the future.
source = fs.readFileSync(url);
source = loaderReadFile(url);
Comment thread
mcollina marked this conversation as resolved.
} else if (protocol === 'data:') {
const result = dataURLProcessor(url);
if (result === 'failure') {
Expand Down
Loading
Loading