Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
72 changes: 72 additions & 0 deletions benchmark/vfs/bench-fs-dispatch.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
'use strict';

// Measures the dispatch overhead the VFS layer adds to fs operations
// against real-filesystem paths (paths that no VFS claims). The hot
// path in lib/fs.js is:
//
// const h = vfsState.handlers;
// if (h !== null) { const r = h.opSync(path, ...); if (r !== undefined) return r; }
// binding.op(getValidatedPath(path));
//
// With layers=0 the VFS module is never required and `h === null` is
// the first thing fs sees. With layers>=1 the handler walks
// activeVFSList calling vfs.shouldHandle(path) on each. The benchmark
// mounts N VFSes at distinct, unrelated mount points and probes a real
// file under __dirname, so every call falls through after the VFS list
// declines the path. That isolates per-layer dispatch cost.

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'],
// 0 = VFS module never loaded (true baseline)
// >=1 = that many VFS instances mounted at unrelated paths
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(`/vfs-bench-${i}`);
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));

// Warm-up - get the JIT past the first-call icache + IC misses so we
// measure steady-state dispatch cost, not first-call resolution.
for (let i = 0; i < 1000; i++) {
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();
}
}
193 changes: 193 additions & 0 deletions doc/api/vfs.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,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(prefix)`][]; call [`vfs.unmount()`][] (or rely on a
`using` declaration) to detach again.

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

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

### `vfs.mount(prefix)`

<!-- YAML
added: REPLACEME
-->

* `prefix` {string} The path prefix where the VFS will be mounted.
* Returns: {VirtualFileSystem} The VFS instance, for chaining or `using`.

Mounts the virtual file system at the specified path prefix. After
mounting, files in the VFS can be accessed through the `node:fs`
module — and resolved through `require()` and `import` — using paths
that start with the prefix.

If a real file-system path already exists at the mount prefix, the
VFS **shadows** that path: every operation against a path under the
mount point is directed to the VFS until the VFS is unmounted.

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

const myVfs = vfs.create();
myVfs.writeFileSync('/data.txt', 'Hello');
myVfs.mount('/virtual');

fs.readFileSync('/virtual/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`. Mounting two instances at overlapping prefixes
(e.g., `/virtual` and `/virtual/sub`) also throws `ERR_INVALID_STATE`.

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');

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

fs.readFileSync('/virtual/data.txt', 'utf8'); // 'Hello'
} // VFS is automatically unmounted here

fs.existsSync('/virtual/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, at the same or a different
prefix, 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 path as an absolute string, or `null` when
the VFS is not mounted.

### `vfs.layerId`

<!-- YAML
added: REPLACEME
-->

* {number}

A per-process monotonically increasing identifier assigned at
construction. The id is stable across `mount()` / `unmount()` cycles
for the lifetime of the instance, and is independent of the order in
which VFS layers are mounted.

The layer id is the building block for cache scoping (see
[Module loader integration][]):

* it surfaces in `import.meta.url` for ES modules loaded from this
VFS, as a `?vfs-layer=<id>` search parameter, so that the cascaded
loader's caches can be scoped per VFS;
* it appears in the `NODE_DEBUG=vfs` output for `register` and
`deregister` events;
* it appears in the `ERR_INVALID_STATE` error message thrown when two
VFS instances try to mount at overlapping prefixes.

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

const a = vfs.create();
const b = vfs.create();
console.log(a.layerId); // e.g. 0
console.log(b.layerId); // a.layerId + 1
```

### `vfs.provider`

<!-- YAML
Expand Down Expand Up @@ -180,6 +305,69 @@ 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 prefix
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.

```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"}');
myVfs.mount('/virtual');

const greet = require('/virtual/lib');
console.log(greet()); // 'hi'

myVfs.unmount();
```

### Cache scoping and `import.meta.url`

Module loaders maintain caches that survive the lifetime of any

@joyeecheung joyeecheung Jun 28, 2026

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 think it's better not to document the cache behavior at length in case this changes and the documented behavior a maintenance burden. Caching should be considered internal details, although module URLs are observable and it's okay to document the changes.

single VFS. To keep entries from leaking once a VFS is unmounted
without invalidating unrelated real-fs imports, two mechanisms are
combined:

* **CommonJS caches** (`require.cache`, the internal stat and
realpath caches, and the `package.json` caches) are filtered on
`unmount()`: entries whose absolute filename would be claimed by
the VFS going away are deleted. `__filename` and `module.filename`
are unchanged - they remain plain absolute paths.

* **ECMAScript module URLs** are tagged at resolve time. When the
resolver determines that a path belongs to a mounted VFS, it
appends `?vfs-layer=<id>` (where `<id>` is the owning instance's
[`vfs.layerId`][]) to the resolved URL. The tag therefore appears
in `import.meta.url` and in cache keys, and on `unmount()` the
cascaded loader's caches drop just the entries that carry the tag
for the unmounting layer.

```mjs
// inside /virtual/lib/greet.mjs after the VFS above is mounted
console.log(import.meta.url);
// e.g. 'file:///virtual/lib/greet.mjs?vfs-layer=0'
```

User code that compares `import.meta.url` literally should account
for the search parameter; use `new URL(import.meta.url).pathname` or
`fileURLToPath()` to obtain the underlying path.

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 @@ -302,9 +490,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
[Module loader integration]: #module-loader-integration
[`MemoryProvider`]: #class-memoryprovider
[`VirtualFileSystem`]: #class-virtualfilesystem
[`VirtualProvider`]: #class-virtualprovider
[`fs.BigIntStats`]: fs.md#class-fsbigintstats
[`fs.Stats`]: fs.md#class-fsstats
[`node:fs`]: fs.md
[`vfs.layerId`]: #vfslayerid
[`vfs.mount(prefix)`]: #vfsmountprefix
[`vfs.unmount()`]: #vfsunmount
Loading
Loading