Skip to content

Conversation

@mcollina
Copy link
Member

@mcollina mcollina commented Jan 22, 2026

A first-class virtual file system module (node:vfs) with a provider-based architecture that integrates with Node.js's fs module and module loader.

Key Features

  • Provider Architecture - Extensible design with pluggable providers:

    • MemoryProvider - In-memory file system with full read/write support
    • SEAProvider - Read-only access to Single Executable Application assets
    • VirtualProvider - Base class for creating custom providers
  • Standard fs API - Uses familiar writeFileSync, readFileSync, mkdirSync instead of custom methods

  • Mount Mode - VFS mounts at a specific path prefix (e.g., /virtual), clear separation from real filesystem

  • Module Loading - require() and import work seamlessly from virtual files

  • SEA Integration - Assets automatically mounted at /sea when running as a Single Executable Application

  • Full fs Support - readFile, stat, readdir, exists, streams, promises, glob, symlinks

Example

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

// Create a VFS with default MemoryProvider
const myVfs = vfs.create();

// Use standard fs-like API
myVfs.mkdirSync('/app');
myVfs.writeFileSync('/app/config.json', '{"debug": true}');
myVfs.writeFileSync('/app/module.js', 'module.exports = "hello"');

// Mount to make accessible via fs module
myVfs.mount('/virtual');

// Works with standard fs APIs
const config = JSON.parse(fs.readFileSync('/virtual/app/config.json', 'utf8'));
const mod = require('/virtual/app/module.js');

// Cleanup
myVfs.unmount();

SEA Usage

When running as a Single Executable Application, bundled assets are automatically available:

const fs = require('node:fs');

// Assets are automatically mounted at /sea - no setup required
const config = fs.readFileSync('/sea/config.json', 'utf8');
const template = fs.readFileSync('/sea/templates/index.html', 'utf8');

Public API

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

vfs.create([provider][, options])  // Create a VirtualFileSystem
vfs.VirtualFileSystem              // The main VFS class
vfs.VirtualProvider                // Base class for custom providers
vfs.MemoryProvider                 // In-memory provider
vfs.SEAProvider                    // SEA assets provider (read-only)

Disclaimer: I've used a significant amount of Claude Code tokens to create this PR. I've reviewed all changes myself.

@nodejs-github-bot
Copy link
Collaborator

Review requested:

  • @nodejs/single-executable
  • @nodejs/test_runner

@nodejs-github-bot nodejs-github-bot added lib / src Issues and PRs related to general changes in the lib or src directory. needs-ci PRs that need a full CI run. labels Jan 22, 2026
@avivkeller avivkeller added fs Issues and PRs related to the fs subsystem / file system. module Issues and PRs related to the module subsystem. semver-minor PRs that contain new features and should be released in the next minor version. notable-change PRs with changes that should be highlighted in changelogs. needs-benchmark-ci PR that need a benchmark CI run. test_runner Issues and PRs related to the test runner subsystem. labels Jan 22, 2026
@github-actions
Copy link
Contributor

The notable-change PRs with changes that should be highlighted in changelogs. label has been added by @avivkeller.

Please suggest a text for the release notes if you'd like to include a more detailed summary, then proceed to update the PR description with the text or a link to the notable change suggested text comment. Otherwise, the commit will be placed in the Other Notable Changes section.

@Ethan-Arrowood
Copy link
Contributor

Nice! This is a great addition. Since it's such a large PR, this will take me some time to review. Will try to tackle it over the next week.

*/
existsSync(path) {
// Prepend prefix to path for VFS lookup
const fullPath = this.#prefix + (StringPrototypeStartsWith(path, '/') ? path : '/' + path);
Copy link
Member

Choose a reason for hiding this comment

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

Can we use path.join?

Comment on lines +417 to +435
/**
* Gets the underlying VirtualFileSystem instance.
* @returns {VirtualFileSystem}
*/
get vfs() {
return this.#vfs;
}

/**
* Gets the mount prefix for the mock file system.
* @returns {string}
*/
get prefix() {
return this.#prefix;
}
Copy link
Member

Choose a reason for hiding this comment

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

Do these need to be getters? Why can't we expose the actual values.

If a user overwrites them, they can if they wish?

validateObject(files, 'options.files');
}

const { VirtualFileSystem } = require('internal/vfs/virtual_fs');
Copy link
Member

Choose a reason for hiding this comment

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

Shouldn't we import this at the top level / lazy load it at the top level?

ArrayPrototypePush(this.#mocks, {
__proto__: null,
ctx,
restore: restoreFS,
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
restore: restoreFS,
restore: ctx.restore,

nit

* @param {object} [options] Optional configuration
*/
addFile(name, content, options) {
const path = this._directory.path + '/' + name;
Copy link
Member

Choose a reason for hiding this comment

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

Can we use path.join?

let entry = current.getEntry(segment);
if (!entry) {
// Auto-create parent directory
const dirPath = '/' + segments.slice(0, i + 1).join('/');
Copy link
Member

Choose a reason for hiding this comment

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

Let's use path.join

let entry = current.getEntry(segment);
if (!entry) {
// Auto-create parent directory
const parentPath = '/' + segments.slice(0, i + 1).join('/');
Copy link
Member

Choose a reason for hiding this comment

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

path.join?

}
}
callback(null, content);
}).catch((err) => {
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
}).catch((err) => {
}, (err) => {

Comment on lines 676 to 677
const bytesToRead = Math.min(length, available);
content.copy(buffer, offset, readPos, readPos + bytesToRead);
Copy link
Member

Choose a reason for hiding this comment

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

Primordials?

}

callback(null, bytesToRead, buffer);
}).catch((err) => {
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
}).catch((err) => {
}, (err) => {

@avivkeller
Copy link
Member

Left an initial review, but like @Ethan-Arrowood said, it'll take time for a more in depth look

@joyeecheung
Copy link
Member

joyeecheung commented Jan 22, 2026

It's nice to see some momentum in this area, though from a first glance it seems the design has largely overlooked the feedback from real world use cases collected 4 years ago: https://github.com/nodejs/single-executable/blob/main/docs/virtual-file-system-requirements.md - I think it's worth checking that the API satisfies the constraints that users of this feature have provided, to not waste the work that have been done by prior contributors to gather them, or having to reinvent it later (possibly in a breaking manner) to satisfy these requirements from real world use cases.

@codecov
Copy link

codecov bot commented Jan 22, 2026

Codecov Report

❌ Patch coverage is 90.78870% with 313 lines in your changes missing coverage. Please review.
✅ Project coverage is 89.79%. Comparing base (1ad04e2) to head (d501c7b).
⚠️ Report is 5 commits behind head on main.

Files with missing lines Patch % Lines
lib/internal/vfs/module_hooks.js 81.83% 108 Missing and 1 partial ⚠️
lib/internal/vfs/virtual_fs.js 91.91% 107 Missing and 2 partials ⚠️
lib/internal/vfs/entries.js 93.44% 23 Missing ⚠️
lib/internal/vfs/streams.js 88.19% 19 Missing ⚠️
lib/internal/vfs/errors.js 88.53% 18 Missing ⚠️
lib/internal/vfs/router.js 91.11% 12 Missing ⚠️
lib/internal/vfs/fd.js 94.57% 9 Missing ⚠️
lib/internal/main/embedding.js 90.00% 6 Missing ⚠️
lib/internal/vfs/stats.js 96.93% 6 Missing ⚠️
lib/internal/vfs/sea.js 97.87% 2 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main   #61478      +/-   ##
==========================================
- Coverage   89.80%   89.79%   -0.02%     
==========================================
  Files         672      681       +9     
  Lines      203907   207293    +3386     
  Branches    39203    39692     +489     
==========================================
+ Hits       183121   186139    +3018     
- Misses      13113    13475     +362     
- Partials     7673     7679       +6     
Files with missing lines Coverage Δ
lib/fs.js 98.20% <100.00%> (+<0.01%) ⬆️
lib/internal/test_runner/mock/mock.js 98.86% <100.00%> (+0.13%) ⬆️
lib/sea.js 97.93% <100.00%> (+0.16%) ⬆️
lib/internal/vfs/sea.js 97.87% <97.87%> (ø)
lib/internal/main/embedding.js 88.55% <90.00%> (-0.43%) ⬇️
lib/internal/vfs/stats.js 96.93% <96.93%> (ø)
lib/internal/vfs/fd.js 94.57% <94.57%> (ø)
lib/internal/vfs/router.js 91.11% <91.11%> (ø)
lib/internal/vfs/errors.js 88.53% <88.53%> (ø)
lib/internal/vfs/streams.js 88.19% <88.19%> (ø)
... and 3 more

... and 26 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@jimmywarting
Copy link

jimmywarting commented Jan 22, 2026

And why not something like OPFS aka whatwg/fs?

const rootHandle = await navigator.storage.getDirectory()
await rootHandle.getFileHandle('config.json', { create: true })
fs.mount('/app', rootHandle) // to make it work with fs
fs.readFileSync('/app/config.json')

OR

const rootHandle = await navigator.storage.getDirectory()
await rootHandle.getFileHandle('config.json', { create: true })

fs.readFileSync('sandbox:/config.json')

fs.createVirtual seems like something like a competing specification

@mcollina mcollina force-pushed the vfs branch 3 times, most recently from 5e317de to 977cc3d Compare January 23, 2026 08:15
@mcollina
Copy link
Member Author

And why not something like OPFS aka whatwg/fs?

I generally prefer not to interleave with WHATWG specs as much as possible for core functionality (e.g., SEA). In my experience, they tend to perform poorly on our codebase and remove a few degrees of flexibility. (I also don't find much fun in working on them, and I'm way less interested in contributing to that.)

On an implementation side, the core functionality of this feature will be identical (technically, it's missing writes that OPFS supports), as we would need to impact all our internal fs methods anyway.

If this lands, we can certainly iterate on a WHATWG-compatible API for this, but I would not add this to this PR.

@juliangruber
Copy link
Member

Small prior art: https://github.com/juliangruber/subfs

@mcollina mcollina force-pushed the vfs branch 2 times, most recently from 8d711c1 to 73c18cd Compare January 23, 2026 13:19
@Qard
Copy link
Member

Qard commented Jan 23, 2026

I also worked on this a bit on the side recently: Qard@73b8fc6

That is very much in chaotic ideation stage with a bunch of LLM assistance to try some different ideas, but the broader concept I was aiming for was to have a VirtualFileSystem type which would actually implement the entire API surface of the fs module, accepting a Provider type to delegate the internals of the whole cluster of file system types to a singular class managing the entire cluster of fs-related types such that the fs module could actually just be fully converted to:

module.exports = new VirtualFileSystem(new LocalProvider())

I intended for it to be extensible for a bunch of different interesting scenarios, so there's also an S3 provider and a zip file provider there, mainly just to validate that the model can be applied to other varieties of storage systems effectively.

Keep in mind, like I said, the current state is very much just ideation in a branch I pushed up just now to share, but I think there are concepts for extensibility in there that we could consider to enable a whole ecosystem of flexible storage providers. 🙂

Personally, I would hope for something which could provide both read and write access through an abstraction with swappable backends of some variety, this way we could pass around these virtualized file systems like objects and let an ecosystem grow around accepting any generalized virtual file system for its storage backing. I think it'd be very nice for a lot of use cases like file uploads or archive management to be able to just treat them like any other readable and writable file system.

@jimmywarting
Copy link

jimmywarting commented Jan 23, 2026

Personally, I would hope for something which could provide both read and write access through an abstraction with swappable backends of some variety, this way we could pass around these virtualized file systems like objects and let an ecosystem grow around accepting any generalized virtual file system for its storage backing. I think it'd be very nice for a lot of use cases like file uploads or archive management to be able to just treat them like any other readable and writable file system.

just a bit off topic... but this reminds me of why i created this feature request:
Blob.from() for creating virtual Blobs with custom backing storage

Would not lie, it would be cool if NodeJS also provided some type of static Blob.from function to create virtual lazy blobs. could live on fs.blobFrom for now...

example that would only work in NodeJS (based on how it works internally)

const size = 26

const blobPart = BlobFrom({
  size,
  stream (start, end) {
    // can either be sync or async (that resolves to a ReadableStream)
    // return new Response('abcdefghijklmnopqrstuvwxyz'.slice(start, end)).body
    // return new Blob(['abcdefghijklmnopqrstuvwxyz'.slice(start, end)]).stream()
    
    return fetch('https://httpbin.dev/range/' + size, {
      headers: {
        range: `bytes=${start}-${end - 1}`
      }
    }).then(r => r.body)
  }
})

blobPart.text().then(text => {
  console.log('a-z', text)
})

blobPart.slice(-3).text().then(text => {
  console.log('x-z', text)
})

const a = blobPart.slice(0, 6)
a.text().then(text => {
  console.log('a-f', text)
})

const b = a.slice(2, 4)
b.text().then(text => {
  console.log('c-d', text)
})
x-z xyz
a-z abcdefghijklmnopqrstuvwxyz
a-f abcdef
c-d cd

An actual working PoC (I would not rely on this unless it became officially supported by nodejs core - this is a hack)

const blob = new Blob()
const symbols = Object.getOwnPropertySymbols(blob)
const blobSymbol = symbols.map(s => [s.description, s])
const symbolMap = Object.fromEntries(blobSymbol)
const {
  kHandle,
  kLength,
} = symbolMap

function BlobFrom ({ size, stream }) {
  const blob = new Blob()
  if (size === 0) return blob

  blob[kLength] = size
  blob[kHandle] = {
    span: [0, size],

    getReader () {
      const [start, end] = this.span
      if (start === end) {
        return { pull: cb => cb(0) }
      }

      let reader

      return {
        async pull (cb) {
          reader ??= (await stream(start, end)).getReader()
          const {done, value} = await reader.read()
          cb(done ^ 1, value)
        }
      }
    },

    slice (start, end) {
      const [baseStart] = this.span

      return {
        span: [baseStart + start, baseStart + end],
        getReader: this.getReader,
        slice: this.slice,
      }
    }
  }

  return blob
}

currently problematic to do: new Blob([a, b]), new File([blobPart], 'alphabet.txt', { type: 'text/plain' })

also need to handle properly clone, serialize & deserialize, if this where to be sent of to another worker - then i would transfer a MessageChannel where the worker thread asks main frame to hand back a transferable ReadableStream when it needs to read something.

but there are probably better ways to handle this internally in core with piping data directly to and from different destinations without having to touch the js runtime? - if only getReader could return the reader directly instead of needing to read from the ReadableStream using js?

Redesign VFS with a Provider-based architecture:

- Add VirtualProvider base class defining the provider interface
- Add MemoryProvider for in-memory file storage with full read/write
- Add SEAProvider for read-only access to SEA assets
- Add VirtualFileSystem class as thin wrapper delegating to providers
- Add VirtualFileHandle and MemoryFileHandle for file operations
- Register node:vfs as a builtin module (scheme-only access)
- Add comprehensive API documentation in doc/api/vfs.md

The provider interface supports:
- Essential primitives: open, stat, readdir, mkdir, rmdir, unlink, rename
- Derived methods: readFile, writeFile, exists, copyFile, etc.
- Capability flags: readonly, supportsSymlinks
- Dynamic content providers (functions called on each read)
- Lazy directory population
Remove backward compatibility methods (addFile, addDirectory,
addSymlink, has, remove) from VirtualFileSystem. Users should now use
the standard fs-like API (writeFileSync, mkdirSync, symlinkSync,
existsSync, unlinkSync).

For dynamic content and lazy directories, use the provider methods:
- provider.setContentProvider(path, fn) for dynamic file content
- provider.setPopulateCallback(path, fn) for lazy directory population

Also adds:
- MemoryProvider.setReadOnly() to make provider immutable after setup
- Fix router.js to handle root mount point (/) correctly
- Remove setContentProvider() and setPopulateCallback() methods
- Remove getContent() backward compat wrapper from MemoryProvider
- Remove section separator comments from memory.js and sea.js
- Remove related tests for dynamic content and lazy directories
- Update documentation to remove the removed methods
Copy link
Contributor

@Ethan-Arrowood Ethan-Arrowood left a comment

Choose a reason for hiding this comment

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

Some initial comments on docs/api/fs.md only. I'm going to focus on reviewing docs first and then move on to actual implementations.

Comment on lines +8399 to +8401
* A synchronous function `() => string|Buffer` for dynamic content
* An async function `async () => string|Buffer` for async dynamic content

Copy link
Contributor

Choose a reason for hiding this comment

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

Lets add a little more explanation to "dynamic content". I think the code example below already does a better job by including the tidbit "(evaluated on each read)". It'd be good to call that here in the description.

I think its particularly important to call this out for the async one since unknowing users could potentially be making expensive network requests every time that data is read.

Like

const data = await fetchData();
vfs.addFile('/data.json', JSON.stringify(data));

Is quite different from:

vfs.addFile('/data.json', async () => {
  const data = await fetchData();
  return JSON.stringify(data);
});

Comment on lines +8427 to +8431
* `populate` {Function} Optional callback to dynamically populate the directory.

Adds a virtual directory. If `populate` is provided, it receives a scoped VFS
for adding files and subdirectories within this directory. The callback is
invoked lazily on first access.
Copy link
Contributor

Choose a reason for hiding this comment

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

Can populate be async? Can the user safely do async things within it?

Copy link
Member

Choose a reason for hiding this comment

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

Allowing populate to be a sync or async iterable would be helpful here as well.


// Create a VFS without module loading hooks (fs operations only)
const fsOnlyVfs = fs.createVirtual({ moduleHooks: false });
```
Copy link
Contributor

Choose a reason for hiding this comment

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

Missing mjs code block.

Seems like most of the examples in here are missing the mjs counterpart. I'm not going to add comments for all of them.


vfs.unmount();

fs.existsSync('/vfs/test.txt'); // false
Copy link
Contributor

Choose a reason for hiding this comment

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

Does this just unmount the /vfs path, or the entire virtual filesystem?

Like what would fs.existsSync('/test.txt'); return now?

And do remounts work? Like if I vfs.mount('/vfs'); again, would fs.existsSync('/vfs/test.txt'); return true again?

* Returns: {boolean}

Returns `true` if the VFS contains a file or directory at the given path.

Copy link
Contributor

Choose a reason for hiding this comment

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

Missing example.

* `path` {string} The path to remove.
* Returns: {boolean} `true` if the entry was removed, `false` if not found.

Removes a file or directory from the VFS.
Copy link
Contributor

Choose a reason for hiding this comment

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

Would this differ at all from rm, rmSync, unlink, etc. ?


Returns `true` if virtual working directory support is enabled for this VFS
instance. This is determined by the `virtualCwd` option passed to
`fs.createVirtual()`.
Copy link
Contributor

Choose a reason for hiding this comment

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

Can this property be set by the user to enable virtualCwd later? Or should we include in the description this is meant to be read only?

Comment on lines +8649 to +8651
> **Note:** VFS hooks are not automatically shared with worker threads. Each
> worker thread has its own `process` object and must set up its own VFS
> instance if virtual cwd support is needed.
Copy link
Contributor

Choose a reason for hiding this comment

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

This is interesting to me. Isn't process.chdir() not available in worker threads? So I wouldn't expect the VFS to change that 🤔

Ref: https://nodejs.org/api/worker_threads.html#class-worker:~:text=process.chdir()%20and%20process%20methods%20that%20set%20group%20or%20user%20ids%20are%20not%20available.

Copy link
Member

Choose a reason for hiding this comment

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

For that we'd need to move the implementation into C++ and arrange for shared access, which gets complicated very quickly. I think omitting that for now is ideal but we might want to make sure we don't rule it out for later.

(`writeFile`, `appendFile`, etc.) are not supported.
* **No file watching**: `fs.watch()` and `fs.watchFile()` do not work with
virtual files.
* **No real file descriptor**: Virtual file descriptors (10000+) are managed
Copy link
Contributor

Choose a reason for hiding this comment

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

What is the 10000+ part?

Copy link
Contributor

@arcanis arcanis Jan 27, 2026

Choose a reason for hiding this comment

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

Haven't checked but I assume open returns file descriptors (ie integers) higher than 10k (arbitrary number) to denote that they aren't true ones and avoid clashing with actual system fds. That's how we implemented it in the fslib.

Copy link
Contributor

@arcanis arcanis Jan 27, 2026

Choose a reason for hiding this comment

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

Although thinking about it 10k feels a little low for apps that open/close a lot. In fslib we simply reserve the upper byte of an i32, and it seemed to work well (note: you can't use the upper bit; unix requires fds to be signed).

virtual files.
* **No real file descriptor**: Virtual file descriptors (10000+) are managed
separately from real file descriptors.

Copy link
Contributor

Choose a reason for hiding this comment

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

How do fs.Stats work?

@mcollina
Copy link
Member Author

Hold off from
further reviews because I'm trying a new provider-based variant, I'll send a PR against this for @Qard to review.

Copy link
Contributor

@bnb bnb left a comment

Choose a reason for hiding this comment

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

Some suggestions, plus I'd like to request that we also include ESM examples for every cjs example we're adding in doc/api/fs.md.

I'd be happy to commit to the PR directly with ESM examples if you'd like.

const vfs = fs.createVirtual();

// Add files to the VFS
vfs.addFile('/config.json', JSON.stringify({ debug: true }));
Copy link
Contributor

Choose a reason for hiding this comment

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

might be wroth passing something more generic here, to minimize possible confusion about what's being passed.

Suggested change
vfs.addFile('/config.json', JSON.stringify({ debug: true }));
vfs.addFile('/config.json', JSON.stringify({ foo: true }));


// Now files are accessible via standard fs APIs
const config = JSON.parse(fs.readFileSync('/app/config.json', 'utf8'));
console.log(config.debug); // true
Copy link
Contributor

Choose a reason for hiding this comment

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

Paired with my previous suggestion:

Suggested change
console.log(config.debug); // true
console.log(config.foo); // true

const vfs = fs.createVirtual();

// Add files to the VFS
vfs.addFile('/config.json', JSON.stringify({ debug: true }));
Copy link
Contributor

Choose a reason for hiding this comment

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

Paired with previous suggestions:

Suggested change
vfs.addFile('/config.json', JSON.stringify({ debug: true }));
vfs.addFile('/config.json', JSON.stringify({ foo: true }));


// Now files are accessible via standard fs APIs
const config = JSON.parse(fs.readFileSync('/app/config.json', 'utf8'));
console.log(config.debug); // true
Copy link
Contributor

Choose a reason for hiding this comment

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

Paired with previous suggestions:

Suggested change
console.log(config.debug); // true
console.log(config.foo); // true

const vfs = fs.createVirtual();

// Static content
vfs.addFile('/config.json', '{"debug": true}');
Copy link
Contributor

Choose a reason for hiding this comment

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

Paired with previous suggestions:

Suggested change
vfs.addFile('/config.json', '{"debug": true}');
vfs.addFile('/config.json', '{"foo": true}');

const content = fs.readFileSync('/virtual/module.js', 'utf8');
const mod = require('/virtual/module.js');
```

Copy link
Contributor

Choose a reason for hiding this comment

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

Can we give an example of what will happen if you mount a second time? For example:

const fs = require('node:fs');

const vfs = fs.createVirtual();
vfs.addFile('/module.js', 'module.exports = "hello"');
vfs.mount('/virtual');

// Now accessible at /virtual/module.js
const content = fs.readFileSync('/virtual/module.js', 'utf8');
const mod = require('/virtual/module.js');

vfs.mount('/different'); 

// what happens if we use mod? what happens if we use load `/different/module.js`?

Comment on lines +8499 to +8520
#### `vfs.unmount()`

<!-- YAML
added: REPLACEME
-->

Unmounts the VFS, removing it from the file system namespace. After unmounting,
the virtual files are no longer accessible through standard `fs` operations.

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

const vfs = fs.createVirtual();
vfs.addFile('/test.txt', 'content');
vfs.mount('/vfs');

fs.existsSync('/vfs/test.txt'); // true

vfs.unmount();

fs.existsSync('/vfs/test.txt'); // false
```
Copy link
Contributor

Choose a reason for hiding this comment

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

IMO makes more sense to have this directly after mount?

Copy link
Member

Choose a reason for hiding this comment

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

Our doc conventions really prefer that these are listed in alphabetical order

Comment on lines +8649 to +8651
> **Note:** VFS hooks are not automatically shared with worker threads. Each
> worker thread has its own `process` object and must set up its own VFS
> instance if virtual cwd support is needed.
Copy link
Contributor

Choose a reason for hiding this comment

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

"Automatically" here implies that VFS hooks can be shared with worker threads. Is that the case? If so, can we mention it here (and point to where users can learn more?)

- Fix template string in documentation examples
- Remove unused imports (isVirtualFd, VFS_FD_BASE, kEntries, isWrite)
- Add createENOTDIR to top-level imports, remove duplicate require
- Use DateNow from primordials instead of Date.now()
- Add MathMin, Boolean to primordials at top of sea.js
- Add parentheses to arrow function parameter
- Use internal error codes instead of new Error()
- Add null-prototype to async return objects
- Fix JSDoc for abstract methods (use @throws instead of @returns)
- Add createENOTEMPTY error creator
- Fix markdown lint (Node.js's -> the Node.js)
SEA assets are automatically mounted at /sea when running as a
Single Executable Application. No user action is required.
@mcollina
Copy link
Member Author

VFS Provider Architecture Update

This PR has been updated with a significant refactoring to introduce a Provider-based architecture. Here's a summary of the changes:

Key Changes

  1. Provider Interface - VFS now uses a provider pattern for extensibility:

    • VirtualProvider - Abstract base class defining the fs-like interface
    • MemoryProvider - In-memory file system with full read/write support
    • SEAProvider - Read-only provider for Single Executable Application assets
  2. Standard fs API - Instead of custom addFile() methods, the VFS now uses standard fs-like methods:

    const vfs = require('node:vfs');
    const myVfs = vfs.create();
    
    myVfs.mkdirSync('/app');
    myVfs.writeFileSync('/app/config.json', '{"port": 3000}');
    myVfs.mount('/virtual');
    
    // Now accessible via standard fs
    require('fs').readFileSync('/virtual/app/config.json', 'utf8');
  3. Simplified SEA Integration - Removed vfs.createSEA(). SEA assets are now automatically mounted at /sea when running as a Single Executable Application. No user code required.

  4. Mount-only mode - Simplified mental model with mount-only (no overlay mode). Clear separation between virtual and real filesystem.

  5. Lint compliance - All code now passes make lint-js and make lint-md:

    • Uses internal error codes (ERR_METHOD_NOT_IMPLEMENTED, ERR_INVALID_STATE)
    • Proper null-prototype objects in async returns
    • Correct JSDoc annotations

Public API

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

// Create a VFS with default MemoryProvider
const myVfs = vfs.create();

// Or with explicit provider
const myVfs = vfs.create(new vfs.MemoryProvider());

// Standard fs operations
myVfs.writeFileSync('/file.txt', 'content');
myVfs.mkdirSync('/dir');

// Mount to make accessible via fs module
myVfs.mount('/virtual');

// For SEA - assets automatically available at /sea
// No code needed - just use fs.readFileSync('/sea/asset.txt')

Exported Classes

  • vfs.create([provider][, options]) - Create a VirtualFileSystem
  • vfs.VirtualFileSystem - The main VFS class
  • vfs.VirtualProvider - Base class for custom providers
  • vfs.MemoryProvider - In-memory provider
  • vfs.SEAProvider - SEA assets provider

cc @Qard

mcollina and others added 2 commits January 28, 2026 22:41
- Use getLazy utility for lazy loading VirtualFileSystem and SEAProvider
- Import getLazy from internal/util instead of manual ??= pattern
- Remove references to non-existent setContentProvider() and
  setPopulateCallback() methods
- Add mjs examples to vfs.create(), promises API, and fs integration
- Clarify unmount/remount behavior - VFS can be remounted after unmount
- Document virtualCwd limitations (construction-time only, no workers)
- Add Implementation details section explaining Stats objects and FD range
- Fix "Note that" lint warning

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@mcollina
Copy link
Member Author

Addressed Review Comments (Ethan-Arrowood)

I've addressed the documentation review comments:

  1. Dynamic content explanation - Removed incorrect references to setContentProvider() and setPopulateCallback() methods which don't exist in the current API
  2. Missing mjs examples - Added ESM examples for vfs.create(), promises API, and fs integration sections
  3. unmount() behavior - Clarified that VFS can be remounted after unmount, and that unmounting resets virtual cwd
  4. virtualCwd limitations - Documented that it must be enabled at construction time, throws ERR_INVALID_STATE if not enabled, and should only be used in the main thread (not Worker threads)
  5. FD range (10000+) - Added Implementation details section explaining virtual file descriptors start at 10000 to avoid conflicts
  6. fs.Stats behavior - Documented that VFS returns real fs.Stats objects with all standard properties

Note: Some original comments referenced methods/features from an earlier design iteration that were removed in the Provider architecture refactoring (e.g., populate async support, reset() method).

mcollina and others added 3 commits January 28, 2026 23:24
Virtual cwd works for virtual paths in Workers, but process.chdir()
to real filesystem paths will throw ERR_WORKER_UNSUPPORTED_OPERATION.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
RealFSProvider wraps a real filesystem directory, allowing it to be
mounted at a different VFS path. This is useful for:

- Mounting a directory at a different path
- Enabling virtualCwd support in Worker threads
- Creating sandboxed views of real directories

The provider prevents path traversal attacks by ensuring resolved paths
stay within the configured root directory.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Add VirtualFileSystem, VirtualProvider, MemoryProvider, SEAProvider,
and RealFSProvider to the type-parser for documentation generation.
const kPath = Symbol('kPath');

// FD range: 10000+ to avoid conflicts with real fds
const VFS_FD_BASE = 10_000;
Copy link
Member

Choose a reason for hiding this comment

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

Alternatively here... it might be interesting to use negative fds for vfs nodes. Not sure if practically possible but it would avoid the potential for collision.

-->

* `path` {string} The virtual path for the file.
* `content` {string|Buffer|Function} The file content, or a function that
Copy link
Member

Choose a reason for hiding this comment

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

Suggest not limiting this to Buffer... also support Uint8Array explicitly.

For "dynamic content", why not also accept sync/async iterables here so that the content can be provided by a stream or generator.

We might also want to support Blob and File here.


Adds a virtual file. The `content` can be:

* A `string` or `Buffer` for static content
Copy link
Member

Choose a reason for hiding this comment

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

When a string is provided, is there a default encoding?

* `prefix` {string} The path prefix where the VFS will be mounted.

Mounts the VFS at a specific path prefix. All paths in the VFS become accessible
under this prefix. Only one mount point can be active at a time.
Copy link
Member

Choose a reason for hiding this comment

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

Does this last sentence mean this will throw if it is called twice?

Having vfs.mount(...) return a disposable object that will unmount on disposal would be nice.

const myMount = vfs.mount(prefix);
myMount.unmount();

// or

{
  using myMount = vfs.mount(prefix);
  // ...
}

Copy link
Member

Choose a reason for hiding this comment

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

Alternatively, having vfs itself support Symbol.dispose would be nice (if it is already and I haven't read far enough thru then ignore this ;-) ...)


const vfs = fs.createVirtual();
vfs.addFile('/module.js', 'module.exports = "hello"');
vfs.mount('/virtual');
Copy link
Member

Choose a reason for hiding this comment

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

It would be good for the docs here to explain a bit about what happens if there's already an existing real file system path /virtual. Is it shadowed? Does mount error? etc

Copy link
Member

Choose a reason for hiding this comment

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

If it automatically shadows, then I can see a potential security hole in this. For instance, if I can mount a path that shadows any real file system path, then a module could obfuscate mounting over a critical system path and trick users into reading malicious data or writing data to something that intercepts the data and forwards it somewhere. Not a critical concern given our security model but something that would be worth documenting


Enables overlay mode, where the VFS is checked first for all file system
operations. If a path exists in the VFS, it is used; otherwise, the operation
falls through to the real file system (if `fallthrough` is enabled).
Copy link
Member

Choose a reason for hiding this comment

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

I would add warnings to the docs here about how this could be abused to secretly shadow critical file system paths.

* `path` {string} The path to check.
* Returns: {boolean}

Returns `true` if the VFS contains a file or directory at the given path.
Copy link
Member

Choose a reason for hiding this comment

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

This should document the behavior if "overlay" mode is enabled.

added: REPLACEME
-->

* `path` {string} The path to remove.
Copy link
Member

Choose a reason for hiding this comment

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

This should document the behavior when "overlay" mode is enabled.

const mod = require('/virtual/module.js');
```

#### `vfs.overlay()`
Copy link
Member

Choose a reason for hiding this comment

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

I think I'd actually prefer this to be an option passed to createVirtual

When the VFS is unmounted, `process.chdir()` and `process.cwd()` are restored
to their original implementations.

> **Note:** VFS hooks are not automatically shared with worker threads. Each
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
> **Note:** VFS hooks are not automatically shared with worker threads. Each
> VFS hooks are not automatically shared with worker threads. Each

console.log(myVfs.provider.readonly); // true
```

### `vfs.mount(prefix)`
Copy link
Member

Choose a reason for hiding this comment

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

This feels like it's duplicating the docs in fs.md a bit too much


* {boolean}

Returns `true` if the provider supports symbolic links.
Copy link
Member

Choose a reason for hiding this comment

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

Symbolic link support needs more explanation. For instance, in "overlay" mode... would it be possible for a real file system symbolic link to target a vfs symbolic link and vis versa? I assume yes but that gets tricky especially when worker threads are involved... sometimes the link works other times it doesn't, etc.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

fs Issues and PRs related to the fs subsystem / file system. lib / src Issues and PRs related to general changes in the lib or src directory. module Issues and PRs related to the module subsystem. needs-benchmark-ci PR that need a benchmark CI run. needs-ci PRs that need a full CI run. notable-change PRs with changes that should be highlighted in changelogs. semver-minor PRs that contain new features and should be released in the next minor version. test_runner Issues and PRs related to the test runner subsystem.

Projects

None yet

Development

Successfully merging this pull request may close these issues.